feat(ui): make a session that cannot send refuse to accept one

A default naming a route the Models page has since removed left the
composer saying 选择模型 while the input still accepted a message, which
then failed inside the adapter mid-turn.

`session.prompt` now refuses with `model-unavailable` before opening a
turn. That is the enforcement boundary: the method stays callable no
matter what a client disables. `session.models` reports the same fact as
`routable`, and ui-model pushes a block through the new
`ctx.conversation.blocks` registry so the bar renders the disabled
textarea it already renders without a workspace, carrying the blocker's
own reason. The push direction is forced — ui-model already depends on
ui-conversation, so ui-conversation cannot read it back.

The gate is `routable`, not "matches no advertised group": catalog
membership is advisory, so a route serving a model it stopped advertising
is missing from the groups yet perfectly usable, and `null` before the
first load never blocks so a slow Host cannot lock a working composer.

The scaffold gains a route-only adapter for fixture-less keyless
scenarios. Registering zero providers is a test artifact — every product
composition mounts one — and the goldens that froze the seat's fallback
label now show the model those scenarios actually route to.
This commit is contained in:
Yichen Jiang
2026-08-07 15:26:42 +08:00
parent 72618f29b5
commit bb43ff4f37
58 changed files with 859 additions and 163 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: fdd27478be25d4352462c2db0fc7eead1d1aee77
README.zh.md: c0fd66b003593e7a535e28751f1f6d0ee80dfac1
README.md: fe8e9851978ebd900fa43eb28f52a5a803501b63
README.zh.md: c11ec125c6bf896c32db74161a263ee2b73c88be

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. 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. 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.
## 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` 引用,与编辑器同一条规则,因此一条为提供方原生认证声明的路由不会一出生就指向一个永远不会被设置的引用。这张卡片提供与编辑器卡片在该 namespace 下相同的提供方级推理等级,两者共用同一个控件:它们写的是同一个 profile 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。
**添加自定义提供方**用来声明 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 字段,若声明时没有它,这个设置就会等到重新打开编辑时才凭空出现。
## 模型体验

View File

@@ -74,7 +74,15 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
const [models, setModels] = useState<readonly ModelDraft[]>([])
const [busy, setBusy] = useState(false)
const [failure, setFailure] = useState<string | undefined>(undefined)
/**
* The profile write landed. Only the key write can still be outstanding, so
* the fields that describe the provider are settled and the retry path is
* the credential alone.
*/
const [committed, setCommitted] = useState(false)
const disabled = props.readOnly || busy
/** Everything but the key stops being editable once the provider exists. */
const profileDisabled = disabled || committed
const routeInvalid = route.length > 0 && !ROUTE_PATTERN.test(route)
const routeTaken = taken.includes(route)
@@ -98,34 +106,42 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
/** Perform the create, returning a failure message or undefined. */
const createOnce = async (): Promise<string | undefined> => {
const keyRef = deriveKeyRef(route)
const storesKey = keyDraft.trim().length > 0
const profile = {
...displayName.length === 0 ? {} : { displayName },
// The profile names the conventional reference only when this card is
// about to store a key, matching the editor: a route declared with the
// key left blank keeps its provider-native auth path (a credential
// chain, ADC) instead of resolving a reference nothing ever sets.
...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 normalizedKey = keyDraft.trim()
const storesKey = normalizedKey.length > 0
if (!committed) {
const profile = {
...displayName.length === 0 ? {} : { displayName },
// The profile names the conventional reference only when this card is
// about to store a key, matching the editor: a route declared with the
// key left blank keeps its provider-native auth path (a credential
// chain, ADC) instead of resolving a reference nothing ever sets.
...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({
ns: NS,
ops: [{ op: 'set', path: ['providers', route], value: profile }],
// `taken` is a snapshot too, so the id check alone cannot see a route
// declared after this card opened; the revision makes that race a
// `settings-conflict` instead of a write over the other profile.
expectedRevision: openedAt,
})
if (!response.result.ok) return response.result.error.message
// The provider now exists. A retry after the key write below fails must
// not re-run this mutate: the revision it holds is the one this write
// just superseded, so the Host would answer `settings-conflict` and the
// key could never be stored from this card at all.
setCommitted(true)
}
const response = await api.settings.mutate({
ns: NS,
ops: [{ op: 'set', path: ['providers', route], value: profile }],
// `taken` is a snapshot too, so the id check alone cannot see a route
// declared after this card opened; the revision makes that race a
// `settings-conflict` instead of a write over the other profile.
expectedRevision: openedAt,
})
if (!response.result.ok) return response.result.error.message
if (storesKey) {
const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
// The profile landed; saying the key did not is the only honest report,
// and the row is now editable so the key can be entered again there.
// and the retry above now goes straight back to this write.
if (!stored.result.ok) return stored.result.error.message
}
return undefined
@@ -163,7 +179,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
value={route}
placeholder="acme-gateway"
aria-label={t('customRoute')}
disabled={disabled}
disabled={profileDisabled}
onChange={(event) => { setRoute(event.target.value) }}
/>
</div>
@@ -178,7 +194,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
value={displayName}
placeholder={route.length === 0 ? t('customDisplayName') : route}
aria-label={t('customDisplayName')}
disabled={disabled}
disabled={profileDisabled}
onChange={(event) => { setDisplayName(event.target.value) }}
/>
</div>
@@ -190,7 +206,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
value={baseURL}
placeholder="https://gateway.example/v1"
aria-label={t('baseUrl')}
disabled={disabled}
disabled={profileDisabled}
onChange={(event) => { setBaseURL(event.target.value) }}
/>
</div>
@@ -200,7 +216,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
className={styles['input']}
value={protocol}
aria-label={t('customApi')}
disabled={disabled}
disabled={profileDisabled}
onChange={(event) => { setProtocol(event.target.value) }}
>
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
@@ -226,7 +242,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
value={effort ?? ''}
onChange={setEffort}
t={t}
disabled={disabled}
disabled={profileDisabled}
/>
<ModelListEditor
models={models}
@@ -239,7 +255,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
}}
api={api}
t={t}
disabled={disabled}
disabled={profileDisabled}
/>
{failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
{/* Only the gates with something to say render; the route-id gate has its
@@ -251,7 +267,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
submitDisabled={disabled || !ready}
submitLabel="create"
submitBusyLabel="creating"
onCancel={() => { props.onClose(false) }}
onCancel={() => { props.onClose(committed) }}
onSubmit={() => { void create() }}
/>
</div>

View File

@@ -650,8 +650,11 @@ describe('provider rows', () => {
})
describe('hand-declared providers', () => {
function mountCard(overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {}) {
const scripted = scriptedFace()
function mountCard(
overrides: Partial<Parameters<typeof CustomProviderCard>[0]> = {},
wire: Parameters<typeof scriptedFace>[0] = {},
) {
const scripted = scriptedFace(wire)
const onClose = vi.fn()
render(
<CustomProviderCard
@@ -736,6 +739,60 @@ describe('hand-declared providers', () => {
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 () => {
const set = vi.fn()
.mockResolvedValueOnce(fail('credential store is read-only', 'credential-rejected'))
.mockResolvedValueOnce(ok({}))
const { mutate, onClose } = mountCard({}, { set })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: ' gw-key ' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
// The profile landed; only the key failed. The card says so and stays open.
await waitFor(() => { expect(screen.getByText('credential store is read-only')).toBeTruthy() })
expect(onClose).not.toHaveBeenCalled()
expect(mutate).toHaveBeenCalledTimes(1)
// The key is stored trimmed, matching the editor.
expect(set).toHaveBeenNthCalledWith(1, { ref: 'ACME_API_KEY', value: 'gw-key' })
// The provider exists now, so the fields describing it are settled and
// only the key can still be corrected.
expect(screen.getByLabelText<HTMLInputElement>(en.customRoute).disabled).toBe(true)
expect(screen.getByLabelText<HTMLInputElement>(en.baseUrl).disabled).toBe(true)
expect(screen.getByLabelText<HTMLInputElement>(en.keyInput).disabled).toBe(false)
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key-2' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
// Re-running the profile write would carry the revision this card's own
// first write superseded, so the Host would answer settings-conflict and
// the key could never be stored from here at all.
expect(mutate).toHaveBeenCalledTimes(1)
expect(set).toHaveBeenNthCalledWith(2, { ref: 'ACME_API_KEY', value: 'gw-key-2' })
})
it('reports the created provider when cancelled after its profile landed', async () => {
const set = vi.fn().mockResolvedValue(fail('nope', 'credential-rejected'))
const { onClose } = mountCard({}, { set })
fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } })
fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } })
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'gw-key' } })
fireEvent.click(screen.getByRole('button', { name: en.addModel }))
fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } })
fireEvent.click(screen.getByText(en.create))
await waitFor(() => { expect(screen.getByText('nope')).toBeTruthy() })
// Walking away leaves a real provider behind; reporting no change would
// leave the page without the row it now has.
fireEvent.click(screen.getByText(en.cancel))
expect(onClose).toHaveBeenCalledWith(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' } })