Преглед на файлове

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.
Yichen Jiang преди 2 месеца
родител
ревизия
5a90eb41fb
променени са 21 файла, в които са добавени 177 реда и са изтрити 91 реда
  1. 2 2
      .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml
  2. 1 1
      .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md
  3. 1 1
      .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md
  4. 9 0
      apps/web/tests/default-model.e2e.ts
  5. 5 6
      apps/web/tests/models-settings.e2e.ts
  6. 2 2
      packages/client/ui-conversation/README.i18n.yaml
  7. 1 1
      packages/client/ui-conversation/README.md
  8. 1 1
      packages/client/ui-conversation/README.zh.md
  9. 8 0
      packages/client/ui-conversation/src/client/contract/slots.ts
  10. 4 1
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
  11. 9 3
      packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
  12. 19 2
      packages/client/ui-conversation/tests/skeleton.spec.tsx
  13. 2 2
      packages/client/ui-models/README.i18n.yaml
  14. 0 1
      packages/client/ui-models/README.md
  15. 0 1
      packages/client/ui-models/README.zh.md
  16. 17 16
      packages/client/ui-models/src/client/CustomProviderCard.tsx
  17. 4 0
      packages/client/ui-models/src/client/ModelsSection.tsx
  18. 26 9
      packages/client/ui-models/src/client/ProviderEditor.tsx
  19. 9 8
      packages/client/ui-models/src/client/ReasoningEffortField.tsx
  20. 4 4
      packages/client/ui-models/src/client/locales.ts
  21. 53 30
      packages/client/ui-models/tests/provider-form.spec.tsx

+ 2 - 2
.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml

@@ -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 .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md
-2026-08-07-default-model-follows-the-picker.md: 4142b3aea6a807001831df62c2038ddf57bbd6ad
-2026-08-07-default-model-follows-the-picker.zh.md: c3566567781edac12cd9269d63f86528139c8796
+2026-08-07-default-model-follows-the-picker.md: d20f0ab8b8c8bd19f596e6ef73f0a58d96c24d38
+2026-08-07-default-model-follows-the-picker.zh.md: 0d2821cb63407fe766e6fe3d36de31d9fc6f1c13

+ 1 - 1
.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md

@@ -30,7 +30,7 @@ A default naming a route the Models page has since removed leaves the composer s
 
 The Host refuses. `session.prompt` checks whether an adapter serves the session's route and answers `model-unavailable` before opening a turn. This is the enforcement boundary: a client that disables its composer is an affordance, and the method stays callable regardless.
 
-The composer goes inert. `session.models` reports `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry; the bar renders the same disabled textarea it already renders without a workspace, with the blocker's own localized reason as the placeholder. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back.
+The composer goes inert. `session.models` reports `routable`, and ui-model pushes a block through the new `ctx.conversation.blocks` registry; the bar renders the same disabled textarea it already renders without a workspace, with the blocker's own localized reason as the placeholder — except the model seat, which a block deliberately leaves live, because choosing a model is how the user clears it. The push direction is forced — ui-model already depends on ui-conversation, so ui-conversation cannot read it back.
 
 The gate is `routable`, NOT "the current target matches no advertised group". Catalog membership is advisory by design: a route serving a model it stopped advertising is absent from the groups yet perfectly usable, and blocking there would break a supported configuration (a narrowed `models` list over a live route). `routable` is also three-valued on the client — `null` before the first load or after a failed one never blocks, so a slow or unreachable Host cannot lock a working composer.
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md

@@ -30,7 +30,7 @@ Status: implemented
 
 宿主拒绝。`session.prompt` 检查是否有适配器服务该会话的路由,在开启轮次之前就以 `model-unavailable` 应答。这是执行边界:客户端禁用编辑器只是提示性设计,这个方法始终可被调用。
 
-编辑器变惰性。`session.models` 报告 `routable`,ui-model 经新的 `ctx.conversation.blocks` 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。
+编辑器变惰性。`session.models` 报告 `routable`,ui-model 经新的 `ctx.conversation.blocks` 注册表推送一个 block;输入栏渲染的仍是它在没有 Workspace 时就会渲染的那个禁用 textarea,只是把抬起方自己的本地化理由作为 placeholder——唯独模型 seat 被 block 刻意保留可用,因为用户正是靠选模型来解除它。推送方向是被迫的——ui-model 本就依赖 ui-conversation,因此 ui-conversation 读不回去。
 
 闸门是 `routable`,**不是**「当前目标匹配不到任何已公布分组」。目录成员关系按设计是咨询性的:一条仍在服务、只是不再公布该模型的路由不在分组里,却完全可用,在那里阻断会破坏一种受支持的配置(对一条活着的路由收窄 `models` 列表)。`routable` 在客户端还是三值的——首次加载之前或加载失败之后的 `null` 绝不阻断,因此慢的或够不着的宿主锁不死一个本来能用的编辑器。
 

+ 9 - 0
apps/web/tests/default-model.e2e.ts

@@ -153,6 +153,15 @@ describe('web e2e: the composer model switch is the default for later sessions',
       },
     })
     expect(refused.result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } })
+
+    // The way out stays open. Locking the model seat with everything else
+    // would leave the composer asking for the one thing it prevents.
+    const seat = page.getByRole('button', { name: /^选择模型/ })
+    expect(await seat.isEnabled()).toBe(true)
+    await seat.click()
+    await page.getByRole('menuitem', { name: /模型/ }).click()
+    await page.getByRole('menuitemradio').first().click()
+    await expect.poll(async () => box.isEnabled(), { timeout: 15_000 }).toBe(true)
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 })

+ 5 - 6
apps/web/tests/models-settings.e2e.ts

@@ -175,7 +175,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     expect(tripwire.pageErrors).toEqual([])
   }, 60_000)
 
-  it('declares a route the adapter does not ship, with its own reasoning effort', async () => {
+  it('declares a route the adapter does not ship, without a reasoning control', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-models-declare'))
     const dialog = page.getByRole('dialog', { name: '设置' })
     const declare = dialog.getByRole('button', { name: '添加自定义提供方' })
@@ -184,10 +184,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await dialog.getByLabel('Provider ID').fill('acme-gateway')
     await dialog.getByLabel('显示名称').fill('Acme Gateway')
     await dialog.getByLabel('API 地址').fill('https://gateway.acme.example/v1')
-    // The create card offers the same provider-level effort the editor card
-    // does for this namespace; a route declared without it would gain the
-    // control only on reopening.
-    await dialog.getByLabel('推理强度').selectOption('high')
+    // No reasoning effort anywhere for a hand-declared route: its models carry
+    // no reasoning capability, so a profile effort would make every model on
+    // the route fail to resolve and drop the provider out of the picker.
+    expect(await dialog.getByLabel('推理强度').count()).toBe(0)
     await dialog.getByRole('button', { name: '添加模型' }).click()
     await dialog.getByLabel('模型 ID 1').fill('acme-large')
     await dialog.getByRole('button', { name: '创建提供方', exact: true }).click()
@@ -196,7 +196,6 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await row.waitFor({ timeout: 10_000 })
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
     expect(document).toContain('acme-gateway:')
-    expect(document).toContain('reasoning: high')
 
     // The tag follows the adapter's installed catalog: this route is in no
     // catalog, while minimax-cn is — even though both now have profiles.

+ 2 - 2
packages/client/ui-conversation/README.i18n.yaml

@@ -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-conversation/README.md
-README.md: 392f9956b33df88a5e9664a58de27d85fc0457d1
-README.zh.md: 6b0429a302475f84a7ce9b1cdc9fd47d90d6dba3
+README.md: ee8a4d240cdc326d158749ae8935ec99bb420d9f
+README.zh.md: 64ac1d15e20a8b60a39a8beb9ae7695543250026

+ 1 - 1
packages/client/ui-conversation/README.md

@@ -8,7 +8,7 @@ Compaction renders as one collapsed row at the checkpoint's flow position withou
 
 The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host.
 
-Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite.
+Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite.
 
 The view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: <active id>`), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome.
 

+ 1 - 1
packages/client/ui-conversation/README.zh.md

@@ -8,7 +8,7 @@
 
 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。
 
-别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。
+别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份契约里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。
 
 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: <active id>`);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。
 

+ 8 - 0
packages/client/ui-conversation/src/client/contract/slots.ts

@@ -265,6 +265,14 @@ export interface ConversationSessionHeaderInjected {
 export interface ComposerBarOwnerProps {
   /** Hero = empty-state centered card; composer = resident bottom bar. */
   variant: 'hero' | 'composer'
+  /**
+   * A block another plugin raised for this session: the bar refuses input and
+   * shows the blocker's reason as the placeholder, but — unlike `disabled` —
+   * keeps the model seat live. Every block this contract has is one the user
+   * clears by choosing a model, so locking that seat too would leave the
+   * composer telling them to do the one thing it prevents.
+   */
+  blocked?: { readonly reason: string }
   /**
    * Inert no-workspace state: the bar renders its normal DOM fully disabled
    * (textarea, add, send) so the workspace pick transitions in place instead

+ 4 - 1
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx

@@ -138,7 +138,10 @@ export function ConversationRoot({
     ...(inert
       ? { disabled: true, placeholder: t('placeholder.workspace') }
       : blocked
-        ? { disabled: true, placeholder: composerBlock.reason }
+        // `blocked`, not `disabled`: the bar refuses input either way, but a
+        // block keeps the model seat live because choosing a model is how the
+        // user clears it.
+        ? { blocked: composerBlock, placeholder: composerBlock.reason }
         : hero ? { placeholder: t('placeholder.hero') } : {}),
     overlay: renderSlot('conversation.input.overlay', {}),
     leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone),

+ 9 - 3
packages/client/ui-conversation/src/client/skeleton/InputBar.tsx

@@ -37,7 +37,8 @@ export type InputBarProps = ComposerBarProps
 export function InputBar({
   useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t,
   renderSlot, useNotices, useLexicon, useMenuLauncher,
-  useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer,
+  useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder,
+  accessory, overlay, leftItems, rightItems, footer,
 }: InputBarProps) {
   const input = useInput(s => s)
   const notice = useNotices(s => s)
@@ -86,8 +87,13 @@ export function InputBar({
   // inert no-workspace state, or the machine faces absent (no session). The
   // transient machine locks (adjudicating pending / submitting) render
   // read-only — the draft stays visible and focused, keystrokes drop.
-  const disabled = removed || inert || !live
+  const disabled = removed || inert || !live || blocked !== undefined
   const locked = disabled
+  // The model seat is the ONE control a block leaves live: every block this
+  // contract has is cleared by choosing a model, so locking it too would leave
+  // the composer asking for the only thing it prevents. The other reasons to
+  // be disabled do lock it — there is no session to choose a model for.
+  const modelSeatLocked = removed || inert || !live
   const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting'
 
   // Scroll the draft scrollport the minimum that brings `caret` into view — the
@@ -512,7 +518,7 @@ export function InputBar({
           </div>
           <div className={css.trailing}>
             {rightItems}
-            {renderSlot('conversation.input.model', { locked })}
+            {renderSlot('conversation.input.model', { locked: modelSeatLocked })}
             <ContextMeter useProjection={useProjection} t={t} />
             {/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
             <Tooltip label={primaryLabel} side="top" delayMs={500}>

+ 19 - 2
packages/client/ui-conversation/tests/skeleton.spec.tsx

@@ -120,9 +120,14 @@ function mount(
   const stop = vi.fn()
   const open = vi.fn()
   const slotCalls: string[] = []
+  /** Owner share handed to the two composer tool-row seats, per render. */
+  const seatOwners: { key: string; owner: unknown }[] = []
   let pickerOwner: unknown
   const renderSlot = ((key: string, owner: object, opts?: { only?: string }) => {
     slotCalls.push(key)
+    if (key === 'conversation.input.model' || key === 'conversation.input.plan') {
+      seatOwners.push({ key, owner })
+    }
     if (key === 'conversation.hero.workspace') { pickerOwner = owner; return null }
     if (key === 'conversation.session.header') {
       return (
@@ -200,7 +205,12 @@ function mount(
           stop={stop}
           command={() => Promise.resolve(true)}
           t={t}
-          renderSlot={(() => null) as InputBarProps['renderSlot']}
+          renderSlot={((key: string, seatOwner: object) => {
+            // The bar's own seats: recorded so a case can assert what share
+            // each tool-row control received.
+            seatOwners.push({ key, owner: seatOwner })
+            return null
+          }) as InputBarProps['renderSlot']}
           {...bar}
         />
       )
@@ -236,7 +246,7 @@ function mount(
   }
   const view = render(<ConversationRoot {...props} />)
   return {
-    view, chat, sink, retargetWorkspace, session, slotCalls, open,
+    view, chat, sink, retargetWorkspace, session, slotCalls, seatOwners, open,
     pickerOwner: () => pickerOwner,
     rerender: () => { view.rerender(<ConversationRoot {...props} />) },
   }
@@ -262,6 +272,13 @@ describe('ConversationRoot resident composer', () => {
     expect(box.placeholder).toBe('select a model first')
     fireEvent.keyDown(box, { key: 'Enter' })
     expect(b.sink).not.toHaveBeenCalled()
+
+    // The model seat stays live. Locking it too would leave the composer
+    // asking for the one thing it prevents — every block this contract has is
+    // cleared by choosing a model.
+    const seat = (key: string) => b.seatOwners.filter(call => call.key === key).at(-1)?.owner
+    expect(seat('conversation.input.model')).toEqual({ locked: false })
+    expect(seat('conversation.input.plan')).toEqual({ locked: true })
   })
 
   it('lets the no-workspace posture win over a block', () => {

+ 2 - 2
packages/client/ui-models/README.i18n.yaml

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

Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
packages/client/ui-models/README.md


Файловите разлики са ограничени, защото са твърде много
+ 0 - 1
packages/client/ui-models/README.zh.md


+ 17 - 16
packages/client/ui-models/src/client/CustomProviderCard.tsx

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

+ 4 - 0
packages/client/ui-models/src/client/ModelsSection.tsx

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

+ 26 - 9
packages/client/ui-models/src/client/ProviderEditor.tsx

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

+ 9 - 8
packages/client/ui-models/src/client/ReasoningEffortField.tsx

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

+ 4 - 4
packages/client/ui-models/src/client/locales.ts

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

+ 53 - 30
packages/client/ui-models/tests/provider-form.spec.tsx

@@ -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()
-
-    // 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
-    expect([...select.options].map(option => option.value))
-      .toEqual(['', 'off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
+  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()
 
-    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' },
+    // 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'],
     })
-
-    // Inherit is the field being absent: an empty string would fail the schema
-    // that types this as an effort name.
+    openEditor('acme-gateway')
+    expect(screen.queryByLabelText(en.effort)).toBeNull()
     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')
+
+    // ...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'])
   })
 
   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' } })

Някои файлове не бяха показани, защото твърде много файлове са промени