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:
Yichen Jiang
2026-08-07 16:45:50 +08:00
parent d03d3ab70b
commit 5a90eb41fb
21 changed files with 179 additions and 91 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
README.md: 1d9c98dfd1e0cec0fa4cb33df9ffe2640be8be05
README.zh.md: d3437c6f13be49ea73d6b3a51bee664b32521e01
README.md: dec43de43899ef99e74b1fd73ffb4bf3c4e97b3e
README.zh.md: c17eb611f071c4054d12d36acb2de8a94fa95a20

View File

@@ -16,7 +16,7 @@ A pi-ai profile's `models` list is edited on the card: one row per model showing
**Fetch available models** asks `llm.discoverModels` about the endpoint the form **currently shows**, including a base URL edited but not yet saved and a key typed but not yet stored, so adding a provider is one pass instead of save-then-return. The reply opens a picker rather than being written: candidates already configured start unchecked, so adopting a selection never overwrites a capacity the user corrected. A provider that cannot be interrogated is a detour, not a dead end — the adapter's own message appears beside the rows, which stay editable by hand.
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. The card offers the same provider-level reasoning effort the editor card does for this namespace, from one shared control: both write the same profile field, so a route declared without it would have gained the setting only on being reopened.
**Add a custom provider** declares a route pi-ai does not ship. It is its own card rather than the editor with extra fields, because the route id is being chosen here and the settings address does not exist until it is: one `settings.mutate` sets the whole profile at `providers.<route>`, and the key travels separately through `credentials.set` under the same `<ROUTE>_API_KEY` derivation an existing provider uses. What a hand-declared route cannot default gates the create button — a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model — so the failure names the field while the user is still looking at it. The id must start with a lowercase letter, because it is also the stem of the derived credential reference and a reference is a POSIX shell identifier: a digit-leading id otherwise passes every check this card makes and then fails at the credential seam with a raw regular expression. Capacities do not gate it: the adapter's fallbacks size a model the endpoint described by id alone, which is what most listings return. The protocol choices are read out of the namespace's own schema rather than a wire field or a constant, so they cannot drift from the ones the adapter accepts. The card records the conventional `apiKeyEnv` reference only when a key is typed, the same rule the editor applies, so a route declared for provider-native authentication is not born pointing at a reference nothing will ever set. When the profile write lands but the key write fails, the provider already exists: the card settles the fields describing it, retries the credential alone — re-running the profile write would carry the revision that write just superseded, so the Host would answer `settings-conflict` and the key could never be stored from here — and reports the created provider even if the user then cancels. Neither this card nor the editor offers a reasoning effort for such a route: a hand-declared model carries no reasoning capability — pi-ai's installed catalog is what supplies one, and it ships nothing under this route — so a profile effort makes `resolveModel` throw for every model on the route and drops the whole provider out of the picker. The editor withholds the control on the directory's `declared` bit for exactly that reason; a route the adapter ships keeps it.
## Model Experience

View File

@@ -16,7 +16,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
**获取可用模型**会针对表单**当前显示**的端点调用 `llm.discoverModels`,包括已修改但尚未保存的 API 地址和已键入但尚未存储的密钥,因此新增一个提供方是一趟走完,而不是「先保存再回来」。回复会打开一个选择框而不是直接写入:已配置过的候选默认不勾选,因此采纳一次选择绝不会覆盖用户已更正的容量。无法被询问的提供方只是绕路而非死路——适配器自己的消息会显示在各行旁边,而这些行仍可手工编辑。
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema而非某个协议字段或常量因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现
**添加自定义提供方**用来声明 pi-ai 未提供的路由。它是独立的一张卡片而非在编辑器上加字段,因为路由 id 正是在这里被*选定*的,而在选定之前 settings 地址并不存在:一次 `settings.mutate` 在 `providers.<route>` 上设置整个 profile密钥则经 `credentials.set` 单独传递,使用与既有提供方相同的 `<ROUTE>_API_KEY` 派生。手工声明的路由无法默认的东西会门控创建按钮——唯一的 **Provider ID**、端点、协议,以及至少一个由唯一标识的模型——因此失败会在用户仍看着该字段时点名它。该 id 必须以小写字母开头,因为它同时是派生凭据引用的词干,而引用是 POSIX shell 标识符:数字开头的 id 否则会通过这张卡片的每一项检查,然后在凭据 seam 上以一条用户无从下手的原始正则失败。容量不参与门控:端点只按 id 描述的模型(这正是多数列表返回的形态)由适配器的回退值定尺寸。协议选项读自该 namespace 自己的 schema而非某个协议字段或常量因此它们不会与适配器实际接受的集合发生漂移。只有键入了密钥这张卡片才记录约定的 `apiKeyEnv` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。当 profile 写入成功而密钥写入失败时,提供方其实已经存在:卡片会把描述它的字段定住,只重试凭据——再跑一次 profile 写入会带着刚被自己这次写入取代的 revision宿主将以 `settings-conflict` 应答,密钥就再也无法从这里存下——并且即使用户随后取消,也照实报告提供方已创建。这类路由在两张卡片上都不提供推理等级:手工声明的模型没有推理能力——能力来自 pi-ai 的已安装 catalog而它在这条路由下什么都没有——因此 profile 级等级会让该路由上每个模型的 `resolveModel` 抛错,整个提供方从选择器里消失。编辑器正是依据目录的 `declared` 位收起这个控件;适配器自带的路由则保留它
## 模型体验

View File

@@ -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}

View File

@@ -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 },
}
}

View File

@@ -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. */}

View File

@@ -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'

View File

@@ -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 协议',

View File

@@ -9,7 +9,7 @@ import { ModelsSection } from '../src/client/ModelsSection.tsx'
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
import { ModelsSettingsStore, protocolChoices } from '../src/client/store.ts'
import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts'
import { en } from '../src/client/locales.ts'
afterEach(cleanup)
@@ -705,38 +705,35 @@ describe('hand-declared providers', () => {
expect(set).toHaveBeenCalledWith({ ref: 'ACME_GATEWAY_API_KEY', value: 'gw-key' })
})
it('offers the same reasoning effort the editor does, and omits it when inherited', async () => {
const { mutate, onClose } = mountCard()
const declare = (): void => {
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
}
declare()
it('offers no reasoning effort at all, in either card, for a hand-declared route', async () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
// A hand-declared model carries no reasoning capability — pi-ai's
// installed catalog is what supplies one, and it ships nothing under this
// route — so a profile effort makes `resolveModel` throw
// UNSUPPORTED_REASONING_EFFORT for every model on it and drops the whole
// provider out of the picker. Offering the control would be offering a way
// to break the route.
expect(screen.queryByLabelText(en.effort)).toBeNull()
cleanup()
// The vocabulary is the namespace's, not DeepSeek's — a route declared
// here is edited by the pi-ai layout, which offers exactly these.
const select = screen.getByLabelText(en.effort) as HTMLSelectElement
// The editor card withholds it for the same route for the same reason...
await mountSection({
providers: { 'acme-gateway': { apiKeyEnv: 'ACME_GATEWAY_API_KEY', baseURL: 'https://acme.test/v1' } },
declaredRoutes: ['acme-gateway'],
})
openEditor('acme-gateway')
expect(screen.queryByLabelText(en.effort)).toBeNull()
cleanup()
// ...and keeps it for a route the adapter actually ships, whose models do
// carry the capability.
await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
openEditor('openai')
const select = screen.getByLabelText<HTMLSelectElement>(en.effort)
expect([...select.options].map(option => option.value))
.toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
fireEvent.change(select, { target: { value: 'high' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
expect(firstMutate(mutate).ops[0]).toMatchObject({
path: ['providers', 'acme'],
value: { reasoning: 'high' },
})
// Inherit is the field being absent: an empty string would fail the schema
// that types this as an effort name.
cleanup()
const second = mountCard()
declare()
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(second.onClose).toHaveBeenCalledWith(true) })
expect(firstMutate(second.mutate).ops[0]).not.toHaveProperty('value.reasoning')
})
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
@@ -793,6 +790,32 @@ describe('hand-declared providers', () => {
expect(onClose).toHaveBeenCalledWith(true)
})
it('refuses a route id whose derived credential reference would be illegal', () => {
mountCard()
const routeField = screen.getByLabelText(en.customRoute)
fireEvent.change(routeField, { target: { value: 'https://acme.test/v1' } })
// A digit-leading id used to pass every check this card makes and then
// fail at the credential seam with a raw regular expression: the
// reference derives as `123_API_KEY`, and a credential reference is a
// POSIX shell identifier, which cannot start with a digit.
fireEvent.change(routeField, { target: { value: '123' } })
expect(screen.getByText(en.customRouteInvalid)).toBeTruthy()
expect(buttonNamed(en.create).disabled).toBe(true)
fireEvent.change(routeField, { target: { value: 'a1' } })
expect(screen.queryByText(en.customRouteInvalid)).toBeNull()
})
it('derives a reference the credential seam accepts for every id it admits', () => {
// The two rules have to stay in step; this is the relation, checked
// directly rather than through the DOM.
const CREDENTIAL_REF = /^[A-Za-z_][A-Za-z0-9_]*$/
for (const id of ['a', 'ds', 'a1', 'acme-gateway', 'x-1-y', 'zz9']) {
expect(CREDENTIAL_REF.test(deriveKeyRef(id))).toBe(true)
}
})
it('names the blocked gate under the form, and nothing once it is satisfied', () => {
mountCard()
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })