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:
@@ -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. */}
|
||||
|
||||
Reference in New Issue
Block a user