fix(web): edit a declared provider's name and protocol

The Models editor card curated its pi-ai fields by what every route has,
so `displayName` and `api` — the two a hand-declared route names for
itself — were asked for at creation and then reachable only through
settings.yaml. The editor now renders both for a route the directory
reports as declared, from the same namespace schema the create card
reads. A catalog route gets neither: it defaults its name from its
catalog entry, and its models each carry their own protocol, so a
route-level one could only override all of them.

Clearing the name unsets it and the route falls back to its id, which is
what the field's placeholder shows; storing the empty string would be
refused by the adapter. A declared profile naming no protocol selects
nothing rather than reading as if it had picked the first choice.

The Provider ID stays fixed: it is the settings dict key, it is
referenced from `agent-default-model` and every logged request header,
and it is the stem of a credential reference the page can never read
back to move.

Fixes #2204
This commit is contained in:
Yichen Jiang
2026-08-10 22:40:57 +08:00
parent cd226191a8
commit 72344fce93
14 changed files with 300 additions and 21 deletions
@@ -54,6 +54,8 @@ interface EditorTarget extends ProviderIdentity {
settingsPath: readonly string[]
/** Writable credential identified under this page's conventional reference. */
credentialRef?: string
/** The adapter reports this route as one it does not ship (see {@link ProviderEditorProps.declared}). */
declared?: boolean
}
/** Values that vary around the shared provider-editor rendering. */
@@ -71,6 +73,7 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
provider={target.provider}
displayName={target.displayName}
settingsPath={target.settingsPath}
{...target.declared === true ? { declared: true } : {}}
{...props}
/>
)
@@ -135,6 +138,10 @@ function targetOf(row: ProviderRow): EditorTarget {
settingsNs: row.entry.settingsNs,
settingsPath: row.entry.settingsPath,
...credentialRef === undefined ? {} : { credentialRef },
// Absent is not "shipped": an adapter that answers nothing leaves the
// route-level fields only a declared route owns off the card, exactly as
// it leaves the custom tag off the row.
...row.entry.declared === true ? { declared: true } : {},
}
}
@@ -7,7 +7,9 @@
* a key is entered; a blank key materializes a reference-free profile for
* provider-native authentication);
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
* both families and DeepSeek's id/name/context-window model catalog).
* both families, DeepSeek's id/name/context-window model catalog, and the
* wire protocol of a pi-ai route the adapter does not ship — the field the
* create card asked that route for, editable here for the same reason).
* Reasoning effort is deliberately absent: it is a per-MODEL capability, and
* the models under one provider disagree about it, so a provider-scoped
* control can only be set to a value some of them reject. The composer's
@@ -30,7 +32,7 @@ import {
import { apiKeyFailure } from './apiKey.ts'
import { EditorFooter } from './EditorFooter.tsx'
import { ModelListEditor } from './ModelListEditor.tsx'
import { deriveKeyRef, messageOf } from './store.ts'
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
import type { en } from './locales.ts'
import styles from './ModelsSection.module.css'
@@ -48,6 +50,14 @@ export interface ProviderEditorProps {
displayName: string
/** Hide the title row (the add card renders its own provider select). */
hideTitle?: boolean
/**
* Whether the adapter reports this route as hand-declared — absent from its
* installed catalog. Such a route carries its own wire protocol, chosen when
* it was created and editable here for the same reason; a catalog route's
* models each carry theirs, so a route-level protocol there could only
* override every one of them and the card does not offer it.
*/
declared?: boolean
/** The owning namespace view (schema, layers, secrets). */
namespace: SettingsNamespaceView
/** Path from the section root to this provider's profile. */
@@ -139,6 +149,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
const disabled = props.readOnly || busy
const layout = layoutOf(namespace.ns)
const keyRef = refFor(namespace, settingsPath, props.provider)
// The same schema read the create card makes, so the choices offered here
// and there cannot drift apart: both come from the adapter's own `Config`.
const protocols = useMemo(() => protocolChoices(namespace), [namespace])
useEffect(() => {
let stale = false
@@ -289,11 +302,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
}
/**
* The curated fields of one known adapter family. Taking the narrowed
* family as a parameter is what makes `EFFORT_FIELD` total here: an
* unknown namespace never reaches this body.
* The curated fields of one known adapter family. The family arrives
* narrowed so the per-family branches below are total: an unknown namespace
* renders the hint instead and never reaches this body.
*/
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
// What a hand-declared route names for itself and nothing else can supply.
// A whole-section `llm-deepseek` profile is a composition fact with no
// per-route identity for its schema to carry, hence the family test.
const ownsIdentity = family === 'pi-ai' && props.declared === true
const customModels = getPath(draft, ['models'])
const modelsOverridden = hasPath(draft, ['models'])
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
@@ -334,6 +351,28 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
<details className={styles['customized']}>
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
<div className={styles['customizedBody']}>
{/* The name and the protocol are the create card's two remaining
profile fields; a route the adapter ships defaults both from
its catalog entry and neither belongs on its card. */}
{ownsIdentity
? (
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
<input
className={styles['input']}
type="text"
value={stringAt(draft, 'displayName') ?? ''}
// The route id, not the stored name: it is what this route
// is called the moment the field is cleared, and clearing
// is the only way back to it.
placeholder={props.provider}
aria-label={t('customDisplayName')}
disabled={disabled}
onChange={(event) => { setField('displayName', event.target.value) }}
/>
</div>
)
: null}
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
<input
@@ -350,6 +389,29 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
}}
/>
</div>
{/* The protocol sits beside the endpoint it describes, as it does
on the create card. */}
{ownsIdentity
? (
<div className={styles['field']}>
<span className={styles['fieldLabel']}>{t('customApi')}</span>
<select
className={styles['input']}
value={probeApi ?? ''}
aria-label={t('customApi')}
disabled={disabled}
onChange={(event) => { setField('api', event.target.value) }}
>
{/* A profile naming no protocol — hand-written into
settings.yaml with no model to need one — selects
nothing rather than reading as if it had picked the
first choice. */}
{probeApi === undefined ? <option value="" /> : null}
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
</select>
</div>
)
: null}
{/* Both families edit the same rows through the same contract; only
the extras differ — DeepSeek's inherited capacities, pi-ai's
endpoint interrogation. */}