fix(ui-models): three faults the running app surfaced
**A hand-declared route must not offer a reasoning effort.** The earlier commit read the create card's missing control as drift and added one. It is the other way round: such a model has no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under the route — so `resolveModel` throws UNSUPPORTED_REASONING_EFFORT for every model on it and the whole provider drops out of the picker. Verified against the adapter, not inferred. The create card no longer offers it and the editor withholds it on the directory's `declared` bit, which is the real bug: that control has always been wrong for these routes. **A blocked composer locked the way out of the block.** Reusing the no-workspace inert posture disabled the model seat along with everything else, so the bar asked for a model while preventing the one control that picks one. A block now rides its own `blocked` owner prop: the textarea, send, commands, plan seat, and access chip all lock, and the model seat alone stays live. **A Provider ID could derive an illegal credential reference.** The card accepted a digit-leading id, whose derived `123_API_KEY` then failed at the credential seam with a raw regular expression the user cannot act on. The id must now start with a letter, and a test pins the relation between the two rules rather than the regex.
This commit is contained in:
@@ -13,6 +13,14 @@
|
||||
* The three fields a hand-declared route cannot default — endpoint, protocol,
|
||||
* and at least one model — are required here rather than at load, so the
|
||||
* failure names the field while the user is still looking at it.
|
||||
*
|
||||
* There is deliberately no reasoning-effort control. A hand-declared model
|
||||
* carries no reasoning capability — pi-ai's installed catalog is what supplies
|
||||
* one, and it has nothing under this route — so a profile effort here makes
|
||||
* `resolveModel` throw UNSUPPORTED_REASONING_EFFORT for every model on the
|
||||
* route, which drops the whole provider out of the model picker. The editor
|
||||
* card hides the control for the same reason once the directory reports the
|
||||
* route as declared.
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
@@ -23,7 +31,6 @@ import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import type { ModelDraft } from './ModelListEditor.tsx'
|
||||
import { EFFORT_FIELD, ReasoningEffortField } from './ReasoningEffortField.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
@@ -31,8 +38,15 @@ import styles from './ModelsSection.module.css'
|
||||
/** The settings namespace a hand-declared provider is written into. */
|
||||
const NS = 'llm-pi-ai'
|
||||
|
||||
/** A route id usable as a settings key and as the stem of a credential name. */
|
||||
const ROUTE_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
/**
|
||||
* A route id usable as a settings key AND as the stem of a credential name.
|
||||
* The leading letter is the second half of that: `deriveKeyRef` uppercases the
|
||||
* id and replaces every non-alphanumeric run with `_`, and a credential
|
||||
* reference is a POSIX shell identifier, which cannot start with a digit. A
|
||||
* digit-leading id passes every check this card makes and then fails at the
|
||||
* credential seam with a raw regular expression the user cannot act on.
|
||||
*/
|
||||
const ROUTE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
||||
|
||||
/** Props of {@link CustomProviderCard}. */
|
||||
export interface CustomProviderCardProps {
|
||||
@@ -71,7 +85,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
const [baseURL, setBaseURL] = useState('')
|
||||
const [protocol, setProtocol] = useState(protocols[0] ?? '')
|
||||
const [keyDraft, setKeyDraft] = useState('')
|
||||
const [effort, setEffort] = useState<string | undefined>(undefined)
|
||||
const [models, setModels] = useState<readonly ModelDraft[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failure, setFailure] = useState<string | undefined>(undefined)
|
||||
@@ -128,9 +141,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
...storesKey ? { apiKeyEnv: keyRef } : {},
|
||||
api: protocol,
|
||||
baseURL,
|
||||
// Inherit is the field being absent, not an empty string: the schema
|
||||
// types it as an effort name, and an empty one would fail the write.
|
||||
...effort === undefined ? {} : { [EFFORT_FIELD['pi-ai']]: effort },
|
||||
models: models.map(model => ({ ...model })),
|
||||
}
|
||||
const response = await api.settings.mutate({
|
||||
@@ -251,15 +261,6 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
? null
|
||||
: <p className={styles['error']}>{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}</p>}
|
||||
</div>
|
||||
{/* The same control the editor card shows for this namespace: a route
|
||||
declared here and edited there must offer the same profile. */}
|
||||
<ReasoningEffortField
|
||||
family="pi-ai"
|
||||
value={effort ?? ''}
|
||||
onChange={setEffort}
|
||||
t={t}
|
||||
disabled={profileDisabled}
|
||||
/>
|
||||
<ModelListEditor
|
||||
models={models}
|
||||
onChange={setModels}
|
||||
|
||||
@@ -54,6 +54,8 @@ interface EditorTarget extends ProviderIdentity {
|
||||
settingsPath: readonly string[]
|
||||
/** Writable credential identified under this page's conventional reference. */
|
||||
credentialRef?: string
|
||||
/** Directory passthrough: the owning adapter ships nothing under this route. */
|
||||
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 === undefined ? {} : { declared: target.declared }}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -137,6 +140,7 @@ function targetOf(row: ProviderRow): EditorTarget {
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
...credentialRef === undefined ? {} : { credentialRef },
|
||||
...row.entry.declared === undefined ? {} : { declared: row.entry.declared },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,8 +7,10 @@
|
||||
* 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, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and
|
||||
* DeepSeek's id/name/context-window model catalog). Everything else stays
|
||||
* both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai —
|
||||
* withheld for a hand-declared route, whose models have no reasoning
|
||||
* capability to configure — and DeepSeek's id/name/context-window model
|
||||
* catalog). Everything else stays
|
||||
* owned by `settings.yaml`. Profile edits land as minimal `settings.mutate`
|
||||
* path ops against the stored section — the card reads the redacted
|
||||
* descriptor, so it names only the fields it can see and a stored literal
|
||||
@@ -55,6 +57,13 @@ export interface ProviderEditorProps {
|
||||
api: Pick<IApiClient, 'settings' | 'credentials' | 'llm'>
|
||||
/** Section copy. */
|
||||
t: (key: keyof typeof en) => string
|
||||
/**
|
||||
* Whether the owning adapter knows this route only because configuration
|
||||
* declared it. Such a route's models carry no reasoning capability, so the
|
||||
* effort control is withheld; absent means the adapter draws no such
|
||||
* distinction and the control shows.
|
||||
*/
|
||||
declared?: boolean
|
||||
/** Disable writes (read-only settings provider). */
|
||||
readOnly: boolean
|
||||
/** Close the editor; `changed` reports whether an Apply committed. */
|
||||
@@ -352,13 +361,21 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ReasoningEffortField
|
||||
family={family}
|
||||
value={stringAt(draft, effortField) ?? ''}
|
||||
onChange={(effort) => { setField(effortField, effort) }}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{/* A hand-declared route's models carry no reasoning capability
|
||||
(pi-ai's installed catalog is what supplies one, and it has
|
||||
nothing under such a route), so a profile effort would make
|
||||
`resolveModel` throw for every model on it and drop the whole
|
||||
provider out of the picker. Offering the control at all would
|
||||
be offering a way to break the route. */}
|
||||
{props.declared === true ? null : (
|
||||
<ReasoningEffortField
|
||||
family={family}
|
||||
value={stringAt(draft, effortField) ?? ''}
|
||||
onChange={(effort) => { setField(effortField, effort) }}
|
||||
t={t}
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{/* Both families edit the same rows through the same contract; only
|
||||
the extras differ — DeepSeek's inherited capacities, pi-ai's
|
||||
endpoint interrogation. */}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
/**
|
||||
* The provider-level reasoning-effort select, shared by every card that writes
|
||||
* a provider profile. It lives here rather than inside one card because both
|
||||
* write the SAME field of the same profile: a route declared without this
|
||||
* control and then edited with it would offer a setting the creating user was
|
||||
* never given, which is exactly the drift that put it here.
|
||||
* The provider-level reasoning-effort select: the profile's own default
|
||||
* effort, applied to every model on the route unless a request names one. The
|
||||
* empty option means "inherit", which on the wire is the field being absent
|
||||
* rather than an empty string.
|
||||
*
|
||||
* The value is the profile's own default effort, applied to every model on the
|
||||
* route unless a request names one; the empty option means "inherit", which on
|
||||
* the wire is the field being absent rather than an empty string.
|
||||
* It carries the per-family vocabulary and field name so the editor's two
|
||||
* layouts cannot spell them differently. Only routes the adapter ships get
|
||||
* this control at all — a hand-declared model has no reasoning capability to
|
||||
* configure, and a profile effort over one makes its whole route fail to
|
||||
* resolve — so the create card renders nothing here by construction.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
@@ -77,8 +77,8 @@ export const en = {
|
||||
customTitle: 'Custom provider',
|
||||
customTag: 'Custom',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: 'Lowercase identifier that uniquely names this provider in requests and as its credential name.',
|
||||
customRouteInvalid: 'Use lowercase letters, digits, and dashes.',
|
||||
customRouteHint: 'Lowercase identifier, starting with a letter, that uniquely names this provider in requests and as its credential name.',
|
||||
customRouteInvalid: 'Start with a lowercase letter; then lowercase letters, digits, and dashes.',
|
||||
customRouteTaken: 'A provider already uses this ID.',
|
||||
customDisplayName: 'Display name',
|
||||
customApi: 'API protocol',
|
||||
@@ -172,8 +172,8 @@ export const zh: typeof en = {
|
||||
customTitle: '自定义提供方',
|
||||
customTag: '自定义',
|
||||
customRoute: 'Provider ID',
|
||||
customRouteHint: '小写标识,在请求中唯一标识该提供方,并用于派生凭据名。',
|
||||
customRouteInvalid: '只能使用小写字母、数字和短横线。',
|
||||
customRouteHint: '以小写字母开头的标识,在请求中唯一标识该提供方,并用于派生凭据名。',
|
||||
customRouteInvalid: '需以小写字母开头,之后可用小写字母、数字和短横线。',
|
||||
customRouteTaken: '已有提供方使用了这个 ID。',
|
||||
customDisplayName: '显示名称',
|
||||
customApi: 'API 协议',
|
||||
|
||||
Reference in New Issue
Block a user