Explorar el Código

feat(ui-models)!: single-key hand-written provider editors with derived credential references

The Models page drops the generic schema renderer and the visible
environment-variable field: each editor is a curated per-family card whose
primary input is one write-only API key stored under a derived
<ROUTE>_API_KEY reference (recorded as apiKeyEnv in the pi-ai profile), an
unkeyed whole-section provider opens as its setup card, and the collapsed
customized-settings fold carries baseURL/reasoningEffort (deepseek) or
reasoning (pi-ai). dsh-client-schema-form reduces to the schema/draft model
layer (no React).
Yichen Jiang hace 1 mes
padre
commit
d1bfdbff84

+ 48 - 45
apps/web/tests/models-settings.e2e.ts

@@ -1,10 +1,14 @@
 // Web e2e scenario: the Models settings page end to end through the real
-// wire — the dormant pi-ai directory renders as the add vocabulary, adding a
-// provider writes the settings document and registers the route live (the
-// row's 已启用 badge is the topology invalidation landing), and the key input
-// stores a credential write-only into the harness home's .env. Zero model
-// calls: configuration is pure settings/credentials/llm-domain traffic, so
-// there is no fixture and a stray stream would fail loud on the open seam.
+// wire — the add card offers the dormant pi-ai catalog, typing an API key
+// stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`)
+// while the settings document records only that reference, and the saved
+// route registers live (the row's 已启用 badge is the topology invalidation
+// landing). The customized-settings fold writes the curated reasoning field
+// as a merge patch. Zero model calls: configuration is pure
+// settings/credentials/llm-domain traffic, so there is no fixture and a
+// stray stream would fail loud on the open seam. The provider under test is
+// minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can
+// never shadow the derived reference.
 import { readFile } from 'node:fs/promises'
 import { fileURLToPath } from 'node:url'
 import { join } from 'node:path'
@@ -42,7 +46,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await scaffold?.close()
   })
 
-  it('renders the dormant directory as the add vocabulary', async () => {
+  it('opens the add card over the dormant directory vocabulary', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-models-empty'))
     await page.getByRole('button', { name: '设置', exact: true }).click()
     const dialog = page.getByRole('dialog', { name: '设置' })
@@ -50,60 +54,59 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await dialog.getByRole('button', { name: '模型' }).click()
     await dialog.getByText('填入各提供方的 API 密钥即可使用其模型。').waitFor({ timeout: 10_000 })
     // The dormant pi-ai adapter contributes its whole installed catalog; no
-    // provider is configured yet, so the page is one add-select.
-    const add = dialog.getByLabel('添加提供方')
+    // provider is configured yet, so the page is one add button.
+    const add = dialog.getByRole('button', { name: '+ 添加提供方' })
     await add.waitFor({ timeout: 10_000 })
-    // The select renders before the directory join settles; poll until the
-    // dormant catalog landed.
-    await expect.poll(async () => add.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
-    const options = await add.locator('option').allTextContents()
+    // The button enables once the dormant catalog lands in the join.
+    await expect.poll(async () => add.isEnabled(), { timeout: 10_000 }).toBe(true)
+    await add.click()
+    const pick = dialog.getByLabel('提供方')
+    await pick.waitFor({ timeout: 10_000 })
+    await expect.poll(async () => pick.locator('option').count(), { timeout: 10_000 }).toBeGreaterThan(30)
+    const options = await pick.locator('option').allTextContents()
     expect(options).toContain('anthropic')
-    expect(options).toContain('openai')
+    expect(options).toContain('minimax-cn')
+    await pick.selectOption('minimax-cn')
+    await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 })
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
   }, 60_000)
 
-  it('adds a provider through the schema-driven editor and the route registers live', async () => {
+  it('stores the key under the derived reference and the route registers live', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add'))
     const dialog = page.getByRole('dialog', { name: '设置' })
-    await dialog.getByLabel('添加提供方').selectOption('anthropic')
-    // The editor is the real pi-ai profile schema rendered field by field;
-    // the credential-reference control is the role-tagged override.
-    const ref = dialog.getByLabel('API 密钥环境变量')
-    await ref.waitFor({ timeout: 10_000 })
-    // A test-owned reference name keeps this hermetic: a developer's real
-    // ANTHROPIC_API_KEY in the process environment must not flip the badge.
-    await ref.fill('E2E_ANTHROPIC_KEY')
+    await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax')
     await dialog.getByRole('button', { name: '保存', exact: true }).click()
-    // The write lands in settings.yaml, the dormant route registers, the
-    // topology frame invalidates the page, and the reloaded join shows the
-    // row live with its credential still missing.
-    const row = dialog.getByText('anthropic', { exact: true }).first()
+    // The profile lands in settings.yaml with only the derived reference, the
+    // key value lands in the harness home's .env, the dormant route
+    // registers, and the topology frame invalidates the page into the row.
+    const row = dialog.getByText('minimax-cn', { exact: true }).first()
     await row.waitFor({ timeout: 10_000 })
     await dialog.getByText('已启用').waitFor({ timeout: 10_000 })
-    await dialog.getByText('缺少密钥').waitFor({ timeout: 10_000 })
     const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
-    expect(document).toContain('llm-pi-ai:')
-    expect(document).toContain('anthropic:')
-    expect(document).toContain('apiKeyEnv: E2E_ANTHROPIC_KEY')
+    expect(document).toContain('minimax-cn:')
+    expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
+    expect(document).not.toContain('sk-e2e-minimax')
+    const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
+    expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax')
+    expect(await page.content()).not.toContain('sk-e2e-minimax')
   }, 60_000)
 
-  it('stores the API key write-only and the badge flips configured', async () => {
-    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-key'))
+  it('applies a customized-settings field as a merge patch', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized'))
     const dialog = page.getByRole('dialog', { name: '设置' })
     await dialog.getByRole('button', { name: '编辑' }).click()
-    const key = dialog.getByLabel('API 密钥', { exact: true })
-    await key.waitFor({ timeout: 10_000 })
-    await key.fill('sk-ant-e2e-test')
-    await dialog.getByRole('button', { name: '保存密钥' }).click()
-    await dialog.getByText('已配置', { exact: true }).waitFor({ timeout: 10_000 })
-    // The value went to the harness home's .env — and nowhere in the DOM.
-    const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8')
-    expect(stored).toContain('E2E_ANTHROPIC_KEY=sk-ant-e2e-test')
-    expect(await page.content()).not.toContain('sk-ant-e2e-test')
-    await dialog.getByRole('button', { name: '取消' }).click()
-    // The row badge converges from the credentials invalidation.
-    await expect.poll(async () => dialog.getByText('缺少密钥').count(), { timeout: 10_000 }).toBe(0)
+    await dialog.getByText('自定义设置').click()
+    const effort = dialog.getByLabel('推理强度')
+    await effort.waitFor({ timeout: 10_000 })
+    await effort.selectOption('high')
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    // The editor closes back to the row; the fold's write merged into the
+    // stored profile beside the reference.
+    await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0)
+    const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')
+    expect(document).toContain('reasoning: high')
+    expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY')
     const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
     await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE)
     await page.keyboard.press('Escape')

+ 2 - 39
apps/web/tests/snapshots/models-settings/configured.expected.md

@@ -14,44 +14,7 @@
   - paragraph: 填入各提供方的 API 密钥即可使用其模型。
   - list:
     - listitem:
-      - text: anthropic 已启用
+      - text: minimax-cn 已启用
       - button "编辑"
       - button "删除"
-  - combobox "添加提供方":
-    - option "+ 添加提供方" [selected]
-    - option "amazon-bedrock"
-    - option "ant-ling"
-    - option "azure-openai-responses"
-    - option "cerebras"
-    - option "cloudflare-ai-gateway"
-    - option "cloudflare-workers-ai"
-    - option "deepseek"
-    - option "fireworks"
-    - option "github-copilot"
-    - option "google"
-    - option "google-vertex"
-    - option "groq"
-    - option "huggingface"
-    - option "kimi-coding"
-    - option "minimax"
-    - option "minimax-cn"
-    - option "mistral"
-    - option "moonshotai"
-    - option "moonshotai-cn"
-    - option "nvidia"
-    - option "openai"
-    - option "openai-codex"
-    - option "opencode"
-    - option "opencode-go"
-    - option "openrouter"
-    - option "qwen-token-plan"
-    - option "qwen-token-plan-cn"
-    - option "together"
-    - option "vercel-ai-gateway"
-    - option "xai"
-    - option "xiaomi"
-    - option "xiaomi-token-plan-ams"
-    - option "xiaomi-token-plan-cn"
-    - option "xiaomi-token-plan-sgp"
-    - option "zai"
-    - option "zai-coding-cn"
+  - button "+ 添加提供方"

+ 9 - 3
apps/web/tests/snapshots/models-settings/empty.expected.md

@@ -13,8 +13,8 @@
   - heading "模型" [level=2]
   - paragraph: 填入各提供方的 API 密钥即可使用其模型。
   - list
-  - combobox "添加提供方":
-    - option "+ 添加提供方" [selected]
+  - text: 提供方
+  - combobox "提供方":
     - option "amazon-bedrock"
     - option "ant-ling"
     - option "anthropic"
@@ -31,7 +31,7 @@
     - option "huggingface"
     - option "kimi-coding"
     - option "minimax"
-    - option "minimax-cn"
+    - option "minimax-cn" [selected]
     - option "mistral"
     - option "moonshotai"
     - option "moonshotai-cn"
@@ -52,3 +52,9 @@
     - option "xiaomi-token-plan-sgp"
     - option "zai"
     - option "zai-coding-cn"
+  - text: API 密钥
+  - textbox "API 密钥":
+    - /placeholder: 输入 API 密钥
+  - group: 自定义设置
+  - button "取消"
+  - button "保存"

+ 2 - 2
packages/client/schema-form/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/schema-form/README.md
-README.md: d6819ccf29cf58667d518c43c43b875eb20e02e9
-README.zh.md: 2f2e07d41db2af5f0afc3352072e7d49129ce6f2
+README.md: 23e69f80914b400a77c036192f564d32bc148310
+README.zh.md: b26593d971d0c53d1fd8d0778200914a90b9b891

+ 5 - 13
packages/client/schema-form/README.md

@@ -2,21 +2,15 @@
 
 English | [中文](README.zh.md)
 
-Schema-driven React form renderer for settings sections. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `SchemaForm` rehydrates it with `new Schema(json)` and renders every declared field as an editable control — the same schema object that validates a section on the host validates and drives the form in the browser, so there is no second form definition to drift.
+Schema/draft model layer for settings editors. The wire's `settings.describe` carries each namespace's serialized schemastery schema (`schema.toJSON()` ref envelope); `rehydrateSchema` turns it back into a live validator with `new Schema(json)` — the same schema object that validates a section on the host validates drafts in the browser, so client-side validation never drifts from the seam's. Editors render their own controls (the Models page hand-writes its card around the fields it probes here); this package owns no React and no rendering.
 
 ## Contract
 
-`SchemaForm` is a controlled component over a **draft user section**: `draft` is the object being edited (never mutated; every edit calls `onChange` with a new root), and `fallback` is the resolved value (schema defaults → composition base → user layer) used for inherited display. A field's presence in the draft marks it **overridden** and shows a per-field Reset that deletes the key, falling back to the inherited layer — presence semantics, not value comparison, exactly mirroring the settings seam's layering.
-
-Controls by schema node: `object` → labeled field groups (JSDoc `description` rendered, `required` starred), `string`/`number`/`boolean` → inputs with the inherited value as placeholder, `union` of literals → select whose empty option means "inherit", `array` → positional rows with add/remove (arrays replace wholesale on write), `dict` → keyed rows where a union-typed `sKey` becomes the add-select's vocabulary. `role('secret')` renders a **write-only** password input: the stored value never arrives (the wire strips it), and the `secrets` slot list (`{path, set}`) supplies the placeholder state. A node the renderer cannot faithfully edit (non-literal unions, transforms) renders a read-only JSON view with a notice instead of disappearing — a schema field is never silently dropped.
-
-`renderField(context)` is the role-aware override hook: return a node to replace the default control for one leaf. The Models settings page uses it to mount the credential-reference control (`role('credential-ref')`) that talks to `credentials.*` — this package stays wire-free and side-effect-free.
-
-`validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages validate before writing; the path helpers (`getPath`/`hasPath`/`setPath`/`deletePath`) expose the same immutable draft editing the controls use.
+The unit of editing is a **draft user section**: a plain object edited immutably (`setPath` materializes intermediates, `deletePath` is the per-field reset — dropping the key falls the resolved value back to the composition base and schema defaults). A field's presence in the draft marks it **overridden** (`hasPath`) — presence semantics, not value comparison, exactly mirroring the settings seam's layering. `nodeAtPath` resolves the schema node addressed by a configurable-provider directory `settingsPath` (object properties by name, dict entries through `inner`), so an editor can probe which fields a provider's profile carries (and their `meta.role`) before deciding what to render; an unresolvable path returns `undefined` so the caller degrades loudly instead of rendering a wrong subtree. `validateDraft(schema, draft)` runs the rehydrated validator and returns its failure message, letting pages reject an invalid draft before writing.
 
 ## Model Experience
 
-None, as this package renders browser configuration forms; nothing here reaches a model request.
+None, as this package backs browser configuration editors; nothing here reaches a model request.
 
 #### KV Cache effect
 
@@ -24,7 +18,5 @@ None; this package neither assembles nor sends a provider request.
 
 ## Known Limitations and Deferred Work
 
-- **Validation is form-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); inline per-field error placement is deferred until a second consumer needs it.
-- **Strings are built-in English** — the `labels` prop overrides every user-visible string, but there is no locale-dictionary wiring inside this package; the embedding page owns localization.
-- **Non-literal unions and transforms render read-only** — faithful editing of those shapes needs per-shape controls; today they fall back to the JSON view with a notice.
-- **Array editing replaces wholesale** — element-level merge does not exist at the settings seam either; the form mirrors that contract rather than hiding it.
+- **Validation is draft-level, not per-field** — `validateDraft` reports schemastery's first failure message (which names the `$.path`); per-field error mapping is deferred until a consumer needs it.
+- **No generic renderer** — a schema-driven form component was built and then replaced by the hand-written Models editor ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)); if a future page needs to edit arbitrary sections, it starts from these helpers, not from a resurrected generic renderer, unless the note's trade-off changes.

+ 5 - 13
packages/client/schema-form/README.zh.md

@@ -2,21 +2,15 @@
 
 [English](README.md) | 中文
 
-面向 settings 分节的 schema 驱动 React 表单渲染器。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`SchemaForm` 用 `new Schema(json)` 将其还原(rehydrate),并把每个已声明的字段渲染为可编辑控件——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验并驱动表单的那份对象,因此不存在第二份会漂移的表单定义
+面向 settings 编辑器的 schema/草稿模型层。wire 侧的 `settings.describe` 携带每个 namespace 的序列化 schemastery schema(`schema.toJSON()` 的 ref 信封);`rehydrateSchema` 用 `new Schema(json)` 将其还原(rehydrate)为活的校验器——在宿主上校验分节的那份 schema 对象,就是在浏览器里校验草稿的那份对象,因此客户端校验绝不会偏离 seam 侧的校验。编辑器各自渲染自己的控件(Models 页围绕它在此探测到的字段手写自己的卡片);该包(package)不含任何 React,也不做任何渲染
 
 ## 契约
 
-`SchemaForm` 是围绕**用户分节草稿**的受控组件:`draft` 是正在编辑的对象(绝不被原地修改;每次编辑都以新的根对象调用 `onChange`),`fallback` 则是用于展示继承值的解析值(schema 默认值 → 组合 base → 用户层)。字段只要出现在草稿中就被标记为**已覆盖**,并显示一个删除该键、回退到继承层的逐字段 Reset——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。
-
-控件按 schema 节点分派:`object` → 带标签的字段组(渲染 JSDoc `description`,`required` 字段加星标);`string`/`number`/`boolean` → 以继承值为占位符的输入框;字面量 `union` → 下拉框,空选项表示「继承」;`array` → 按位置排列、可增删的行(数组在写入时整体替换);`dict` → 按键排列的行,其中联合类型的 `sKey` 成为「新增」下拉框的词汇。`role('secret')` 渲染为**只写**的密码输入框:已存储的值永远不会送达(wire 会剥除它),占位状态由 `secrets` 槽位列表(`{path, set}`)提供。渲染器无法忠实编辑的节点(非字面量联合、转换(transform)节点)渲染为带提示的只读 JSON 视图,而不是直接消失——schema 字段绝不会被静默丢弃。
-
-`renderField(context)` 是感知角色的覆盖钩子:返回一个节点,即可替换单个叶子字段的默认控件。Models 设置页用它挂载与 `credentials.*` 通信的凭据引用控件(`role('credential-ref')`)——该包(package)自身始终不接触 wire,也没有副作用。
-
-`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以先校验再写入;路径辅助函数(`getPath`/`hasPath`/`setPath`/`deletePath`)对外暴露的不可变草稿编辑,与控件内部使用的是同一套。
+编辑的单元是**用户分节草稿**:一个以不可变方式编辑的普通对象(`setPath` 会物化中间对象,`deletePath` 即逐字段重置——去掉该键,解析值便回退到组合 base 与 schema 默认值)。字段只要出现在草稿中就被标记为**已覆盖**(`hasPath`)——判定采用存在性语义而非值比较,与 settings seam 的分层方式严格对应。`nodeAtPath` 解析可配置提供方目录 `settingsPath` 所寻址的 schema 节点(object 属性按名称解析,dict 条目经由 `inner`),编辑器因此可以在决定渲染什么之前,先探测某提供方的 profile 携带哪些字段(及其 `meta.role`);无法解析的路径返回 `undefined`,调用方因此会大声降级,而不是渲染出错误的子树。`validateDraft(schema, draft)` 运行还原出的校验器并返回其失败消息,页面因此可以在写入前拒绝无效草稿。
 
 ## Model Experience
 
-无。该包渲染的是浏览器配置表单;这里没有任何内容进入模型请求。
+无。该包支撑的是浏览器配置编辑器;这里没有任何内容进入模型请求。
 
 #### KV Cache effect
 
@@ -24,7 +18,5 @@
 
 ## Known Limitations and Deferred Work
 
-- **校验是表单级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的内联报错展示延后到出现需要它的第二个消费方再做。
-- **字符串内置为英文**——`labels` prop 可以覆盖每一条用户可见字符串,但包内没有接入语言环境词典的接线;本地化归嵌入它的页面所有。
-- **非字面量联合与转换节点只读渲染**——忠实编辑这些形状需要逐形状的控件;目前它们回退为带提示的 JSON 视图。
-- **数组编辑整体替换**——settings seam 同样不存在元素级合并;表单如实呈现该契约,而不是把它藏起来。
+- **校验是草稿级的,而非逐字段**——`validateDraft` 报告 schemastery 的第一条失败消息(其中会点名 `$.path`);逐字段的报错映射延后到出现需要它的消费方再做。
+- **没有通用渲染器**——一个 schema 驱动的表单组件曾被构建出来,随后被手写的 Models 编辑器取代([Agent Note(agent 决策记录)](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md));若未来有页面需要编辑任意分节,起点是这些辅助函数,而不是复活后的通用渲染器——除非该 note 的权衡发生变化。

+ 1 - 3
packages/client/schema-form/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@deepseek-ai/dsh-client-schema-form",
-  "description": "Schema-driven React form renderer: rehydrates a serialized schemastery schema and renders/edits a settings draft against it",
+  "description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path",
   "version": "0.0.1",
   "private": true,
   "type": "module",
@@ -20,7 +20,6 @@
   },
   "license": "BSD-3-Clause",
   "dependencies": {
-    "react": "^18.2.0",
     "schemastery": "^3.18.0"
   },
   "peerDependencies": {
@@ -29,7 +28,6 @@
   },
   "devDependencies": {
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@types/react": "~18.3.1",
     "cordis": "^4.0.0-rc.7"
   },
   "files": [

+ 0 - 99
packages/client/schema-form/src/SchemaForm.module.css

@@ -1,99 +0,0 @@
-.fields {
-  display: flex;
-  flex-direction: column;
-  gap: 14px;
-}
-
-.field {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-}
-
-.field.group {
-  border: 1px solid var(--border, #e2e2e2);
-  border-radius: 10px;
-  padding: 12px;
-}
-
-.labelRow {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 8px;
-}
-
-.label {
-  font-size: 13px;
-  font-weight: 500;
-  color: var(--text-secondary, #555);
-}
-
-.description {
-  margin: 0;
-  font-size: 12px;
-  color: var(--text-tertiary, #888);
-}
-
-.control {
-  width: 100%;
-  box-sizing: border-box;
-  padding: 8px 10px;
-  border: 1px solid var(--border, #d9d9d9);
-  border-radius: 8px;
-  font: inherit;
-  background: var(--surface, #fff);
-  color: inherit;
-}
-
-.control:focus {
-  outline: 2px solid var(--accent, #3964fe);
-  outline-offset: -1px;
-}
-
-.resetButton {
-  border: none;
-  background: none;
-  color: var(--accent, #3964fe);
-  font-size: 12px;
-  cursor: pointer;
-  padding: 0;
-}
-
-.stack {
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.row {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.row > :first-child {
-  flex: 1;
-}
-
-.dictKey {
-  min-width: 96px;
-  font-size: 13px;
-  font-weight: 500;
-}
-
-.unsupported {
-  display: flex;
-  flex-direction: column;
-  gap: 4px;
-  font-size: 12px;
-  color: var(--text-tertiary, #888);
-}
-
-.unsupported pre {
-  margin: 0;
-  padding: 8px;
-  border-radius: 8px;
-  background: var(--surface-sunken, #f5f5f5);
-  overflow-x: auto;
-}

BIN
packages/client/schema-form/src/SchemaForm.tsx


+ 0 - 6
packages/client/schema-form/src/css-modules.d.ts

@@ -1,6 +0,0 @@
-declare module '*.module.css' {
-  const classes: Record<string, string>
-  export default classes
-}
-
-declare module '*.css'

+ 6 - 10
packages/client/schema-form/src/index.ts

@@ -1,16 +1,12 @@
 /**
- * Schema-driven React form renderer for settings sections. `SchemaForm`
- * rehydrates the wire's serialized schemastery envelope and edits a draft
- * user section against it; the model helpers expose the same introspection
- * and immutable path editing for page-level composition.
+ * Schema/draft model layer for settings editors: rehydrate the wire's
+ * serialized schemastery envelope, resolve nodes by settings path, validate
+ * drafts, and edit them immutably by path. Editors render their own controls
+ * (the Models page hand-writes its layout) on top of these helpers.
  * @module @deepseek-ai/dsh-client-schema-form
  */
 
-export { SchemaForm } from './SchemaForm.tsx'
-export type {
-  SchemaFieldContext, SchemaFormLabels, SchemaFormProps, SchemaFormSecret,
-} from './SchemaForm.tsx'
 export {
-  deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
+  deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
 } from './model.ts'
-export type { NodeKind, SchemaNode } from './model.ts'
+export type { SchemaNode } from './model.ts'

+ 4 - 4
packages/client/schema-form/src/invariant.ts

@@ -15,10 +15,10 @@ export const name = 'client-schema-form-invariant'
 export const inject = ['invariants']
 
 /**
- * No runtime invariant: a pure React rendering library — it emits no cordis
- * events and owns no cross-plugin mutable relation; draft immutability,
- * schema rehydration, and control/edit round trips are asserted directly by
- * this package's component and model specs.
+ * No runtime invariant: a pure schema/draft helper library — it emits no
+ * cordis events and owns no cross-plugin mutable relation; draft
+ * immutability, schema rehydration, and path-edit round trips are asserted
+ * directly by this package's model specs.
  */
 const install: InvariantInstaller = () => {}
 

+ 3 - 46
packages/client/schema-form/src/model.ts

@@ -1,8 +1,8 @@
 /**
- * Schema introspection and draft-editing helpers behind the form renderer.
+ * Schema introspection and draft-editing helpers behind settings editors.
  * The serialized schemastery envelope (`schema.toJSON()`) rehydrates into a
- * live validator whose node relations (`dict`/`inner`/`list`) the renderer
- * walks; drafts are edited immutably by path.
+ * live validator whose node relations (`dict`/`inner`) editors probe for
+ * field presence and roles; drafts are edited immutably by path.
  * @module @deepseek-ai/dsh-client-schema-form/model
  */
 
@@ -35,49 +35,6 @@ export function validateDraft(schema: SchemaNode, draft: unknown): string | unde
   }
 }
 
-/** The renderable classification of one schema node. */
-export type NodeKind =
-  | 'object'
-  | 'dict'
-  | 'array'
-  | 'string'
-  | 'number'
-  | 'boolean'
-  | 'union-const'
-  | 'unsupported'
-
-/**
- * Classify one node into the renderer's vocabulary. A union renders as a
- * select only when every branch is a literal; everything else the renderer
- * cannot faithfully edit is `unsupported` and falls back to a read-only view
- * (never silently dropped).
- * @param node - live schema node.
- * @returns the control family for this node.
- */
-export function nodeKind(node: SchemaNode): NodeKind {
-  switch (node.type) {
-    case 'object': return 'object'
-    case 'dict': return 'dict'
-    case 'array': return 'array'
-    case 'string': return 'string'
-    case 'number': return 'number'
-    case 'boolean': return 'boolean'
-    case 'union':
-      return (node.list ?? []).every(branch => branch.type === 'const') ? 'union-const' : 'unsupported'
-    default:
-      return 'unsupported'
-  }
-}
-
-/**
- * Literal choices of a `union-const` node, in declaration order.
- * @param node - a node classified `union-const`.
- * @returns each branch's literal value.
- */
-export function unionChoices(node: SchemaNode): unknown[] {
-  return (node.list ?? []).map(branch => (branch as { value?: unknown }).value)
-}
-
 /**
  * Resolve the schema node at a settings path (the configurable-provider
  * directory's `settingsPath` vocabulary): object properties by name, dict

+ 1 - 28
packages/client/schema-form/tests/model.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import Schema from 'schemastery'
 import {
-  deletePath, getPath, hasPath, nodeAtPath, nodeKind, rehydrateSchema, setPath, unionChoices, validateDraft,
+  deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
 } from '../src/model.ts'
 
 const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
@@ -21,33 +21,6 @@ describe('rehydration and validation', () => {
   })
 })
 
-describe('nodeKind', () => {
-  it.each([
-    [Schema.object({}), 'object'],
-    [Schema.dict(Schema.string()), 'dict'],
-    [Schema.array(Schema.string()), 'array'],
-    [Schema.string(), 'string'],
-    [Schema.number(), 'number'],
-    [Schema.natural(), 'number'],
-    [Schema.boolean(), 'boolean'],
-    [Schema.union(['a', 'b']), 'union-const'],
-    [Schema.union([Schema.string(), Schema.number()]), 'unsupported'],
-    [Schema.transform(Schema.string(), value => value), 'unsupported'],
-  ])('classifies %#', (schema, expected) => {
-    expect(nodeKind(rehydrateSchema(Wire(schema as Schema)))).toBe(expected)
-  })
-
-  it('lists union choices in declaration order', () => {
-    const node = rehydrateSchema(Wire(Schema.union(['off', 'high', 'max'])))
-    expect(unionChoices(node)).toEqual(['off', 'high', 'max'])
-  })
-
-  it('tolerates structural union nodes missing their branch list', () => {
-    expect(nodeKind({ type: 'union', meta: {} } as never)).toBe('union-const')
-    expect(unionChoices({ type: 'union', meta: {} } as never)).toEqual([])
-  })
-})
-
 describe('path helpers', () => {
   const root = { providers: { openai: { baseURL: 'https://x' } }, models: [{ id: 'a' }] }
 

+ 0 - 346
packages/client/schema-form/tests/schema-form.spec.tsx

@@ -1,346 +0,0 @@
-// @vitest-environment jsdom
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, describe, expect, it, vi } from 'vitest'
-import Schema from 'schemastery'
-import { SchemaForm } from '../src/index.ts'
-
-afterEach(cleanup)
-
-const Wire = (schema: Schema): unknown => JSON.parse(JSON.stringify(schema.toJSON()))
-
-const Profile = Schema.object({
-  apiKey: Schema.string().role('secret'),
-  apiKeyEnv: Schema.string().role('credential-ref'),
-  baseURL: Schema.string().description('Endpoint override'),
-  reasoning: Schema.union(['off', 'high', 'max']),
-  timeoutMs: Schema.number().min(0).max(1000).step(1),
-  verbose: Schema.boolean(),
-  name: Schema.string().required(),
-})
-
-function lastDraft(onChange: ReturnType<typeof vi.fn>): Record<string, unknown> {
-  return onChange.mock.calls.at(-1)?.[0] as Record<string, unknown>
-}
-
-describe('leaf controls', () => {
-  it('renders strings with inherited placeholders, writes on input, clears on empty', () => {
-    const onChange = vi.fn()
-    render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{ baseURL: 'https://mine' }}
-      fallback={{ baseURL: 'https://base', reasoning: 'high' }}
-      onChange={onChange}
-    />)
-    const input = screen.getByDisplayValue('https://mine')
-    fireEvent.change(input, { target: { value: 'https://next' } })
-    expect(lastDraft(onChange)).toEqual({ baseURL: 'https://next' })
-    fireEvent.change(input, { target: { value: '' } })
-    expect(lastDraft(onChange)).toEqual({})
-    const inherited = screen.getByPlaceholderText('Default: https://base')
-    expect(inherited).toBeTruthy()
-  })
-
-  it('renders numbers with bounds and parses edits', () => {
-    const onChange = vi.fn()
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{}}
-      fallback={{ timeoutMs: 500 }}
-      onChange={onChange}
-    />)
-    const input = container.querySelector('input[type="number"]') as HTMLInputElement
-    expect(input.placeholder).toBe('Default: 500')
-    expect(input.min).toBe('0')
-    expect(input.max).toBe('1000')
-    fireEvent.change(input, { target: { value: '250' } })
-    expect(lastDraft(onChange)).toEqual({ timeoutMs: 250 })
-  })
-
-  it('clears a number override back to inherited on empty input', () => {
-    const onChange = vi.fn()
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{ timeoutMs: 250 }}
-      onChange={onChange}
-    />)
-    const input = container.querySelector('input[type="number"]') as HTMLInputElement
-    expect(input.value).toBe('250')
-    fireEvent.change(input, { target: { value: '' } })
-    expect(lastDraft(onChange)).toEqual({})
-  })
-
-  it('prefers an overridden boolean over the fallback', () => {
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{ verbose: false }}
-      fallback={{ verbose: true }}
-      onChange={vi.fn()}
-    />)
-    const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement
-    expect(box.checked).toBe(false)
-  })
-
-  it('reflects booleans from the fallback until overridden', () => {
-    const onChange = vi.fn()
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{}}
-      fallback={{ verbose: true }}
-      onChange={onChange}
-    />)
-    const box = container.querySelector('input[type="checkbox"]') as HTMLInputElement
-    expect(box.checked).toBe(true)
-    fireEvent.click(box)
-    expect(lastDraft(onChange)).toEqual({ verbose: false })
-  })
-
-  it('renders literal unions as selects with an inherit option', () => {
-    const onChange = vi.fn()
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{}}
-      fallback={{ reasoning: 'high' }}
-      onChange={onChange}
-    />)
-    const select = container.querySelector('select') as HTMLSelectElement
-    expect([...select.options].map(option => option.text)).toEqual(['Default: high', 'off', 'high', 'max'])
-    fireEvent.change(select, { target: { value: 'max' } })
-    expect(lastDraft(onChange)).toEqual({ reasoning: 'max' })
-  })
-
-  it('clears a union override back to inherit', () => {
-    const onChange = vi.fn()
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{ reasoning: 'max' }}
-      onChange={onChange}
-    />)
-    const select = container.querySelector('select') as HTMLSelectElement
-    expect(select.value).toBe('max')
-    fireEvent.change(select, { target: { value: '' } })
-    expect(lastDraft(onChange)).toEqual({})
-  })
-
-  it('marks required fields and surfaces descriptions', () => {
-    render(<SchemaForm schema={Wire(Profile)} draft={{}} onChange={vi.fn()} />)
-    expect(screen.getByText('Endpoint override')).toBeTruthy()
-    expect(screen.getByText('name').textContent).toContain('name')
-    expect(screen.getByText('*')).toBeTruthy()
-  })
-
-  it('shows the per-field reset only for overridden fields and deletes on click', () => {
-    const onChange = vi.fn()
-    render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{ baseURL: 'https://mine' }}
-      onChange={onChange}
-    />)
-    const resets = screen.getAllByText('Reset')
-    expect(resets).toHaveLength(1)
-    fireEvent.click(resets[0] as HTMLElement)
-    expect(lastDraft(onChange)).toEqual({})
-  })
-})
-
-describe('secrets and custom renderers', () => {
-  it('renders secrets write-only with the stored-state placeholder', () => {
-    const onChange = vi.fn()
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{}}
-      secrets={[{ path: ['apiKey'], set: true }]}
-      onChange={onChange}
-    />)
-    const input = container.querySelector('input[type="password"]') as HTMLInputElement
-    expect(input.placeholder).toBe('Configured — enter a new value to replace')
-    expect(input.value).toBe('')
-    fireEvent.change(input, { target: { value: 'sk-new' } })
-    expect(lastDraft(onChange)).toEqual({ apiKey: 'sk-new' })
-  })
-
-  it('clears a typed-but-unsaved secret back to unset', () => {
-    const onChange = vi.fn()
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{ apiKey: 'sk-draft' }}
-      onChange={onChange}
-    />)
-    const input = container.querySelector('input[type="password"]') as HTMLInputElement
-    expect(input.value).toBe('sk-draft')
-    fireEvent.change(input, { target: { value: '' } })
-    expect(lastDraft(onChange)).toEqual({})
-  })
-
-  it('reports an unset secret slot', () => {
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{}}
-      secrets={[{ path: ['apiKey'], set: false }]}
-      onChange={vi.fn()}
-    />)
-    const input = container.querySelector('input[type="password"]') as HTMLInputElement
-    expect(input.placeholder).toBe('Not configured')
-  })
-
-  it('lets renderField replace a role-tagged control', () => {
-    render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{ apiKeyEnv: 'OPENAI_API_KEY' }}
-      onChange={vi.fn()}
-      renderField={(context) => {
-        if (context.role !== 'credential-ref') return undefined
-        return <div data-testid="credential-control">{String(context.draftValue)}</div>
-      }}
-    />)
-    expect(screen.getByTestId('credential-control').textContent).toBe('OPENAI_API_KEY')
-  })
-
-  it('disables every control under disabled', () => {
-    const { container } = render(<SchemaForm
-      schema={Wire(Profile)}
-      draft={{}}
-      disabled
-      onChange={vi.fn()}
-    />)
-    for (const input of container.querySelectorAll('input, select, button')) {
-      expect((input as HTMLInputElement).disabled).toBe(true)
-    }
-  })
-})
-
-describe('containers', () => {
-  const Catalog = Schema.object({
-    models: Schema.array(Schema.object({ id: Schema.string().required() })),
-    retryPolicy: Schema.object({ maxRetries: Schema.number() }),
-  })
-
-  it('renders nested object groups', () => {
-    render(<SchemaForm schema={Wire(Catalog)} draft={{}} onChange={vi.fn()} />)
-    expect(screen.getByText('retryPolicy')).toBeTruthy()
-    expect(screen.getByText('maxRetries')).toBeTruthy()
-  })
-
-  it('materializes fallback rows into the draft on add and edit', () => {
-    const onChange = vi.fn()
-    render(<SchemaForm
-      schema={Wire(Catalog)}
-      draft={{}}
-      fallback={{ models: [{ id: 'flash' }] }}
-      onChange={onChange}
-    />)
-    fireEvent.click(screen.getByText('Add'))
-    expect(lastDraft(onChange)).toEqual({ models: [{ id: 'flash' }, {}] })
-    fireEvent.change(screen.getByPlaceholderText('Default: flash'), { target: { value: 'pro' } })
-    expect(lastDraft(onChange)).toEqual({ models: [{ id: 'pro' }] })
-  })
-
-  it('removes draft array rows wholesale', () => {
-    const onChange = vi.fn()
-    render(<SchemaForm
-      schema={Wire(Catalog)}
-      draft={{ models: [{ id: 'flash' }, { id: 'pro' }] }}
-      onChange={onChange}
-    />)
-    fireEvent.click(screen.getAllByText('Remove')[0] as HTMLElement)
-    expect(lastDraft(onChange)).toEqual({ models: [{ id: 'pro' }] })
-  })
-
-  it('renders dict rows from both layers with removal only for draft keys', () => {
-    const Providers = Schema.object({ providers: Schema.dict(Schema.object({ baseURL: Schema.string() })) })
-    const onChange = vi.fn()
-    render(<SchemaForm
-      schema={Wire(Providers)}
-      draft={{ providers: { openai: { baseURL: 'https://o' } } }}
-      fallback={{ providers: { anthropic: { baseURL: 'https://a' }, openai: { baseURL: 'https://o' } } }}
-      onChange={onChange}
-    />)
-    expect(screen.getByText('anthropic')).toBeTruthy()
-    expect(screen.getByText('openai')).toBeTruthy()
-    const removes = screen.getAllByText<HTMLButtonElement>('Remove')
-    expect(removes.map(button => button.disabled)).toEqual([true, false])
-    fireEvent.click(removes[1] as HTMLElement)
-    expect(lastDraft(onChange)).toEqual({ providers: {} })
-  })
-
-  it('adds dict entries through a free-text key input', () => {
-    const Providers = Schema.object({ providers: Schema.dict(Schema.object({ baseURL: Schema.string() })) })
-    const onChange = vi.fn()
-    render(<SchemaForm schema={Wire(Providers)} draft={{}} onChange={onChange} />)
-    const add = screen.getByLabelText<HTMLInputElement>('Add')
-    fireEvent.keyDown(add, { key: 'a' })
-    expect(onChange).not.toHaveBeenCalled()
-    add.value = 'openai'
-    fireEvent.keyDown(add, { key: 'Enter' })
-    expect(lastDraft(onChange)).toEqual({ providers: { openai: {} } })
-    add.value = ''
-    fireEvent.keyDown(add, { key: 'Enter' })
-    expect(onChange).toHaveBeenCalledTimes(1)
-  })
-
-  it('offers remaining sKey vocabulary as the add select', () => {
-    const Providers = Schema.object({
-      providers: Schema.dict(Schema.object({ baseURL: Schema.string() }), Schema.union(['openai', 'anthropic'])),
-    })
-    const onChange = vi.fn()
-    render(<SchemaForm
-      schema={Wire(Providers)}
-      draft={{ providers: { openai: {} } }}
-      onChange={onChange}
-    />)
-    const add = screen.getByLabelText<HTMLSelectElement>('Add')
-    expect([...add.options].map(option => option.value)).toEqual(['', 'anthropic'])
-    fireEvent.change(add, { target: { value: 'anthropic' } })
-    expect(lastDraft(onChange)).toEqual({ providers: { openai: {}, anthropic: {} } })
-  })
-
-  it('materializes type-shaped empty values for every array inner kind', () => {
-    const Kinds = Schema.object({
-      tags: Schema.array(Schema.string()),
-      nums: Schema.array(Schema.number()),
-      flags: Schema.array(Schema.boolean()),
-      lists: Schema.array(Schema.array(Schema.string())),
-      dicts: Schema.array(Schema.dict(Schema.string())),
-    })
-    const onChange = vi.fn()
-    render(<SchemaForm schema={Wire(Kinds)} draft={{}} onChange={onChange} />)
-    const adds = screen.getAllByText('Add')
-    const expected: Record<string, unknown> = {
-      tags: [''], nums: [0], flags: [false], lists: [[]], dicts: [{}],
-    }
-    Object.entries(expected).forEach(([key, value], index) => {
-      fireEvent.click(adds[index] as HTMLElement)
-      expect(lastDraft(onChange)).toEqual({ [key]: value })
-    })
-  })
-
-  it('falls back to a read-only view for unsupported nodes instead of dropping them', () => {
-    const Mixed = Schema.object({ weird: Schema.union([Schema.string(), Schema.number()]) })
-    render(<SchemaForm
-      schema={Wire(Mixed)}
-      draft={{}}
-      fallback={{ weird: 42 }}
-      onChange={vi.fn()}
-    />)
-    expect(screen.getByText('42')).toBeTruthy()
-    expect(screen.getByText(/no form control/)).toBeTruthy()
-  })
-
-  it('shows the draft value in the read-only fallback view, and nothing when both layers are empty', () => {
-    const Mixed = Schema.object({ weird: Schema.union([Schema.string(), Schema.number()]) })
-    const { container } = render(<SchemaForm
-      schema={Wire(Mixed)}
-      draft={{ weird: 'overridden' }}
-      onChange={vi.fn()}
-    />)
-    expect(screen.getByText('"overridden"')).toBeTruthy()
-    cleanup()
-    const empty = render(<SchemaForm schema={Wire(Mixed)} draft={{}} onChange={vi.fn()} />).container
-    expect((empty.querySelector('pre') as HTMLElement).textContent).toBe('')
-    expect(container).toBeTruthy()
-  })
-
-  it('renders a structural object node without declared properties as an empty group', () => {
-    const { container } = render(<SchemaForm schema={{ type: 'object' }} draft={{}} onChange={vi.fn()} />)
-    expect(container.querySelectorAll('input')).toHaveLength(0)
-  })
-})

+ 0 - 29
packages/client/schema-form/tsdown.config.ts

@@ -1,29 +0,0 @@
-import { defineConfig } from 'tsdown'
-
-/**
- * schema-form is browser-only, but its lib bundle is imported under plain
- * Node through consumer lib chains (same posture as ui-primitives). CSS
- * imports are stubbed to empty modules: the hashed class maps only matter in
- * bundler contexts, which compile src directly and never read lib.
- */
-export default defineConfig({
-  entry: ['lib/types/index.js', 'lib/types/invariant.js'],
-  outDir: 'lib',
-  format: ['esm'],
-  platform: 'neutral',
-  target: 'es2024',
-  fixedExtension: false,
-  dts: false,
-  clean: false,
-  plugins: [{
-    name: 'dsh-css-stub',
-    resolveId(source: string) {
-      if (!source.endsWith('.css')) return null
-      return `\0dsh-css-stub:${source}.mjs`
-    },
-    load(id: string) {
-      if (!id.startsWith('\0dsh-css-stub:')) return null
-      return 'export default {};'
-    },
-  }],
-})

+ 0 - 127
packages/client/ui-models/src/client/CredentialControl.tsx

@@ -1,127 +0,0 @@
-/**
- * Credential-reference control: renders the reference NAME as the editable
- * settings field, its configured state as a badge, and an inline write-only
- * key input that stores the value through `credentials.set`. The value never
- * renders back — the wire has no read path for it.
- */
-
-import { useEffect, useState } from 'react'
-import type { ReactNode } from 'react'
-import type { CredentialView, IApiClient } from '@deepseek-ai/dsh-client-connection/client'
-import type { SchemaFieldContext } from '@deepseek-ai/dsh-client-schema-form'
-import type { en } from './locales.ts'
-import styles from './ModelsSection.module.css'
-
-/** Props of {@link CredentialControl}. */
-export interface CredentialControlProps {
-  /** The `apiKeyEnv` leaf position inside the provider editor's form. */
-  context: SchemaFieldContext
-  /** Credentials wire face. */
-  credentials: IApiClient['credentials']
-  /** Section copy. */
-  t: (key: keyof typeof en) => string
-}
-
-/** The effective reference name this control addresses. */
-function refOf(context: SchemaFieldContext): string | undefined {
-  const value = context.draftValue ?? context.fallbackValue
-  return typeof value === 'string' && value.length > 0 ? value : undefined
-}
-
-/**
- * Render the credential-reference field with its live state and key input.
- * @param props - field context, wire face, and copy.
- * @returns the control column.
- */
-export function CredentialControl(props: CredentialControlProps): ReactNode {
-  const { context, credentials, t } = props
-  const ref = refOf(context)
-  const [state, setState] = useState<CredentialView | undefined>(undefined)
-  const [keyDraft, setKeyDraft] = useState('')
-  const [busy, setBusy] = useState(false)
-  const [failure, setFailure] = useState<string | undefined>(undefined)
-
-  useEffect(() => {
-    let stale = false
-    setState(undefined)
-    if (ref === undefined) return undefined
-    void credentials.describe({ refs: [ref] }).then((response) => {
-      if (stale || !response.result.ok) return
-      setState(response.result.value.credentials[ref])
-    })
-    return () => { stale = true }
-  }, [credentials, ref])
-
-  const badge = state === undefined
-    ? null
-    : state.configured
-      ? (
-        <span className={styles['badgeOk']}>
-          {t('credentialConfigured')}
-          {state.source === 'env' ? ` · ${t('credentialFromEnv')}` : ''}
-        </span>
-      )
-      : <span className={styles['badgeWarn']}>{t('credentialMissing')}</span>
-
-  const storeKey = async (): Promise<void> => {
-    /* v8 ignore next -- the save button is disabled while no reference or draft exists */
-    if (ref === undefined || keyDraft.length === 0) return
-    setBusy(true)
-    setFailure(undefined)
-    const response = await credentials.set({ ref, value: keyDraft })
-    setBusy(false)
-    if (!response.result.ok) {
-      setFailure(response.result.error.message)
-      return
-    }
-    setKeyDraft('')
-    const described = await credentials.describe({ refs: [ref] })
-    if (described.result.ok) setState(described.result.value.credentials[ref])
-  }
-
-  return (
-    <div className={styles['credential']}>
-      <div className={styles['credentialRefRow']}>
-        <input
-          className={styles['input']}
-          type="text"
-          value={typeof context.draftValue === 'string' ? context.draftValue : ''}
-          placeholder={typeof context.fallbackValue === 'string' ? context.fallbackValue : undefined}
-          aria-label={t('credentialRef')}
-          disabled={context.disabled}
-          onChange={(event) => {
-            const next = event.target.value
-            if (next === '') context.clearValue()
-            else context.setValue(next)
-          }}
-        />
-        {badge}
-      </div>
-      {ref !== undefined && state?.writable !== false
-        ? (
-          <div className={styles['credentialKeyRow']}>
-            <input
-              className={styles['input']}
-              type="password"
-              autoComplete="off"
-              value={keyDraft}
-              placeholder={t('keyPlaceholder')}
-              disabled={context.disabled || busy}
-              aria-label={t('keyInput')}
-              onChange={(event) => { setKeyDraft(event.target.value) }}
-            />
-            <button
-              type="button"
-              className={styles['secondaryButton']}
-              disabled={context.disabled || busy || keyDraft.length === 0}
-              onClick={() => { void storeKey() }}
-            >
-              {t('keySave')}
-            </button>
-          </div>
-        )
-        : null}
-      {failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
-    </div>
-  )
-}

+ 113 - 18
packages/client/ui-models/src/client/ModelsSection.module.css

@@ -60,10 +60,21 @@
 }
 
 .badgeOk {
+  display: inline-flex;
+  align-items: center;
+  gap: 5px;
   color: var(--text-success, #0a7d33);
   font-size: 12px;
 }
 
+.badgeOk::before {
+  content: '';
+  width: 6px;
+  height: 6px;
+  border-radius: 999px;
+  background: currentcolor;
+}
+
 .badgeMuted {
   color: var(--text-tertiary, #999);
   font-size: 12px;
@@ -115,16 +126,19 @@
 }
 
 .editor {
-  border-top: 1px solid var(--border, #eee);
-  padding-top: 12px;
+  border: 1px solid var(--border, #e6e6e6);
+  border-radius: 12px;
+  background: var(--surface-secondary, #f7f7f8);
+  padding: 14px 16px;
   display: flex;
   flex-direction: column;
-  gap: 12px;
+  gap: 14px;
 }
 
 .editorHeader {
   display: flex;
-  align-items: center;
+  align-items: baseline;
+  gap: 8px;
 }
 
 .editorTitle {
@@ -132,6 +146,48 @@
   font-weight: 600;
 }
 
+.editorRoute {
+  font-size: 12px;
+  color: var(--text-tertiary, #999);
+}
+
+.field {
+  display: flex;
+  flex-direction: column;
+  gap: 6px;
+}
+
+.fieldLabel {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--text-secondary, #555);
+}
+
+.linkButton {
+  border: none;
+  background: none;
+  padding: 0;
+  color: var(--text-tertiary, #888);
+  font: inherit;
+  font-size: 12px;
+  text-decoration: underline;
+  cursor: pointer;
+}
+
+.linkButton:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
+
+.advancedHint {
+  margin: 0;
+  font-size: 12px;
+  color: var(--text-tertiary, #999);
+}
+
 .editorActions {
   display: flex;
   justify-content: flex-end;
@@ -144,43 +200,82 @@
   gap: 12px;
 }
 
-.addSelect {
+.addButton {
   align-self: flex-start;
   border: 1px solid var(--border, #d9d9d9);
   border-radius: 999px;
-  padding: 8px 14px;
+  padding: 8px 16px;
   font: inherit;
+  font-size: 13px;
   background: var(--surface, #fff);
+  color: inherit;
+  cursor: pointer;
 }
 
-.credential {
+.addButton:disabled {
+  opacity: 0.5;
+  cursor: default;
+}
+
+.addCard,
+.setupCard {
+  border: 1px solid var(--border, #e6e6e6);
+  border-radius: 12px;
+  background: var(--surface-secondary, #f7f7f8);
+  padding: 14px 16px;
   display: flex;
   flex-direction: column;
-  gap: 6px;
+  gap: 14px;
+  list-style: none;
 }
 
-.credentialRefRow,
-.credentialKeyRow {
-  display: flex;
-  align-items: center;
-  gap: 8px;
+.addCard .editor,
+.setupCard .editor {
+  border: none;
+  background: none;
+  padding: 0;
 }
 
-.credentialRefRow > input,
-.credentialKeyRow > input {
-  flex: 1;
+.customized {
+  border-top: 1px solid var(--border, #ececec);
+  padding-top: 10px;
+}
+
+.customizedSummary {
+  cursor: pointer;
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--text-secondary, #555);
+  list-style: revert;
+}
+
+.customizedBody {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+  padding-top: 12px;
 }
 
 .input {
   box-sizing: border-box;
-  padding: 8px 10px;
+  padding: 9px 12px;
   border: 1px solid var(--border, #d9d9d9);
-  border-radius: 8px;
+  border-radius: 10px;
   font: inherit;
+  font-size: 13px;
   background: var(--surface, #fff);
   color: inherit;
 }
 
+.input:focus {
+  outline: none;
+  border-color: var(--accent-strong, #111);
+}
+
+.input::placeholder {
+  color: var(--text-tertiary, #aaa);
+}
+
 .error {
   margin: 0;
   font-size: 12px;

+ 95 - 46
packages/client/ui-models/src/client/ModelsSection.tsx

@@ -1,7 +1,9 @@
 /**
  * Models settings section: the provider rows joined from the configurable
  * directory, settings namespaces, and credential states, with one editor
- * card at a time (edit an existing provider or add a dormant one). Every
+ * card at a time. A whole-section provider without a configured key (the
+ * unconfigured DeepSeek posture) renders as its open setup card instead of a
+ * row; the add flow is a card carrying the dormant-provider select. Every
  * mutation writes through the wire; the page re-renders from the pushed
  * invalidations or the post-apply reload.
  */
@@ -22,7 +24,7 @@ export interface ModelsSectionInjected {
   controller: ModelsSettingsStore
   /** uSES subscription hook bound to the store. */
   useSnapshot: SnapshotSelectorHook<ModelsSettingsState>
-  /** Wire faces the editor and credential control write through. */
+  /** Wire faces the editor writes through. */
   api: Pick<IApiClient, 'settings' | 'credentials'>
   /** Section copy. */
   t: (key: keyof typeof en) => string
@@ -37,6 +39,7 @@ export type ModelsSectionProps = Partial<ModelsSectionInjected>
 /** The editor target: an existing row or a dormant directory entry. */
 interface EditorTarget {
   provider: string
+  displayName: string
   settingsNs: string
   settingsPath: readonly string[]
 }
@@ -62,17 +65,28 @@ export async function removeProviderProfile(
   if (response.result.ok) await controller.load()
 }
 
-function StatusBadges({ row, t }: { row: ProviderRow; t: ModelsSectionInjected['t'] }): ReactNode {
-  return (
-    <span className={styles['badges']}>
-      {row.entry.active
-        ? <span className={styles['badgeOk']}>{t('active')}</span>
-        : <span className={styles['badgeMuted']}>{t('dormant')}</span>}
-      {row.credential !== undefined && !row.credential.configured
-        ? <span className={styles['badgeWarn']}>{t('keyMissing')}</span>
-        : null}
-    </span>
-  )
+/**
+ * Whether a whole-section provider still needs its first key: nothing marks
+ * the credential configured and no literal `apiKey` is stored, so the page
+ * opens the setup card instead of showing a row.
+ * @param row - the joined provider row.
+ * @param namespace - the owning namespace view.
+ * @returns whether to render the setup card.
+ */
+export function needsSetup(row: ProviderRow, namespace: SettingsNamespaceView): boolean {
+  if (row.entry.settingsPath.length > 0) return false
+  if (row.credential?.configured === true) return false
+  return !namespace.secrets.some(secret =>
+    secret.set && secret.path.length === 1 && secret.path[0] === 'apiKey')
+}
+
+function targetOf(row: ProviderRow): EditorTarget {
+  return {
+    provider: row.entry.provider,
+    displayName: row.entry.displayName,
+    settingsNs: row.entry.settingsNs,
+    settingsPath: row.entry.settingsPath,
+  }
 }
 
 /**
@@ -124,20 +138,38 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
       {!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
       <ul className={styles['rows']}>
         {configured.map((row) => {
-          const target: EditorTarget = {
-            provider: row.entry.provider,
-            settingsNs: row.entry.settingsNs,
-            settingsPath: row.entry.settingsPath,
-          }
-          const open = !adding && editing?.provider === row.entry.provider
+          const target = targetOf(row)
           const namespace = state.namespaces.get(target.settingsNs)
           /* v8 ignore next -- the join marks a row configured only when its namespace resolved */
           if (namespace === undefined) return null
+          if (needsSetup(row, namespace)) {
+            // First-run posture: the provider exists but has no key — the
+            // setup card IS its presence on the page.
+            return (
+              <li key={row.entry.provider} className={styles['setupCard']}>
+                <ProviderEditor
+                  provider={target.provider}
+                  displayName={target.displayName}
+                  namespace={namespace}
+                  settingsPath={target.settingsPath}
+                  api={api}
+                  t={t}
+                  readOnly={!state.writable}
+                  onClose={closeEditor}
+                />
+              </li>
+            )
+          }
+          const open = !adding && editing?.provider === row.entry.provider
           return (
             <li key={row.entry.provider} className={styles['rowCard']}>
               <div className={styles['rowHead']}>
                 <span className={styles['rowName']}>{row.entry.displayName}</span>
-                <StatusBadges row={row} t={t} />
+                <span className={styles['badges']}>
+                  {row.entry.active
+                    ? <span className={styles['badgeOk']}>{t('active')}</span>
+                    : <span className={styles['badgeMuted']}>{t('dormant')}</span>}
+                </span>
                 <span className={styles['rowActions']}>
                   <button
                     type="button"
@@ -164,6 +196,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
                 ? (
                   <ProviderEditor
                     provider={target.provider}
+                    displayName={target.displayName}
                     namespace={namespace}
                     settingsPath={target.settingsPath}
                     api={api}
@@ -180,38 +213,54 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
       <div className={styles['addBlock']}>
         {addTarget !== undefined && addNamespace !== undefined
           ? (
-            <ProviderEditor
-              provider={addTarget.provider}
-              namespace={addNamespace}
-              settingsPath={addTarget.settingsPath}
-              api={api}
-              t={t}
-              readOnly={!state.writable}
-              onClose={closeEditor}
-            />
+            <div className={styles['addCard']}>
+              <div className={styles['field']}>
+                <span className={styles['fieldLabel']}>{t('provider')}</span>
+                <select
+                  className={styles['input']}
+                  value={addTarget.provider}
+                  aria-label={t('provider')}
+                  onChange={(event) => {
+                    const row = addable.find(candidate => candidate.entry.provider === event.target.value)
+                    /* v8 ignore next -- the select only lists addable rows */
+                    if (row === undefined) return
+                    setEditing(targetOf(row))
+                  }}
+                >
+                  {addable.map(row => (
+                    <option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
+                  ))}
+                </select>
+              </div>
+              <ProviderEditor
+                key={addTarget.provider}
+                provider={addTarget.provider}
+                displayName={addTarget.displayName}
+                hideTitle
+                namespace={addNamespace}
+                settingsPath={addTarget.settingsPath}
+                api={api}
+                t={t}
+                readOnly={!state.writable}
+                onClose={closeEditor}
+              />
+            </div>
           )
           : (
-            <select
-              className={styles['addSelect']}
-              value=""
+            <button
+              type="button"
+              className={styles['addButton']}
               disabled={addable.length === 0 || !state.writable}
-              aria-label={t('add')}
-              onChange={(event) => {
-                const row = addable.find(candidate => candidate.entry.provider === event.target.value)
-                if (row === undefined) return
+              onClick={() => {
+                const first = addable[0]
+                /* v8 ignore next -- the button is disabled while nothing is addable */
+                if (first === undefined) return
                 setAdding(true)
-                setEditing({
-                  provider: row.entry.provider,
-                  settingsNs: row.entry.settingsNs,
-                  settingsPath: row.entry.settingsPath,
-                })
+                setEditing(targetOf(first))
               }}
             >
-              <option value="">{`+ ${t('add')}`}</option>
-              {addable.map(row => (
-                <option key={row.entry.provider} value={row.entry.provider}>{row.entry.displayName}</option>
-              ))}
-            </select>
+              {`+ ${t('add')}`}
+            </button>
           )}
       </div>
     </div>

+ 204 - 71
packages/client/ui-models/src/client/ProviderEditor.tsx

@@ -1,26 +1,50 @@
 /**
- * One provider's editor card: the schema-driven form over its profile
- * subtree, the credential-reference control, and the Apply/Cancel pair.
- * Apply without removals merges (`settings.update`, preserving stored keys
- * outside the patch); apply after a field reset replaces the user section so
- * the reset actually lands.
+ * One provider's editor card, hand-written per adapter family: the primary
+ * field is a single write-only **API key** input (the page never asks for an
+ * environment-variable name — a typed key stores through `credentials.set`
+ * under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile
+ * has none, and the pi-ai profile records that derivation as `apiKeyEnv`);
+ * the collapsed 自定义设置 area carries the per-family extras (deepseek:
+ * `baseURL` + `reasoningEffort`; pi-ai: `reasoning`). Everything else stays
+ * owned by `settings.yaml` — the folded hint says so. Profile edits land as a
+ * minimal `settings.update` merge patch; clearing a field back to inherited
+ * removes its key, so that apply replaces the user section (safe: the section
+ * stores references, never key values).
  */
 
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
 import type { ReactNode } from 'react'
-import type { IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
+import type { CredentialView, IApiClient, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
 import {
-  getPath, nodeAtPath, rehydrateSchema, SchemaForm, setPath, validateDraft,
+  deletePath, getPath, nodeAtPath, rehydrateSchema, setPath, validateDraft,
 } from '@deepseek-ai/dsh-client-schema-form'
-import type { SchemaFormSecret } from '@deepseek-ai/dsh-client-schema-form'
-import { CredentialControl } from './CredentialControl.tsx'
+import { deriveKeyRef } from './store.ts'
 import type { en } from './locales.ts'
 import styles from './ModelsSection.module.css'
 
+/** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */
+type EditorLayout = 'deepseek' | 'pi-ai' | 'unknown'
+
+/** Reasoning vocabularies per layout; the empty option means "inherit". */
+const EFFORT_CHOICES: Record<'deepseek' | 'pi-ai', readonly string[]> = {
+  deepseek: ['off', 'high', 'max'],
+  'pi-ai': ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'],
+}
+
+/** The draft key the effort select edits, per layout. */
+const EFFORT_FIELD: Record<'deepseek' | 'pi-ai', string> = {
+  deepseek: 'reasoningEffort',
+  'pi-ai': 'reasoning',
+}
+
 /** Props of {@link ProviderEditor}. */
 export interface ProviderEditorProps {
-  /** Provider route id (card title). */
+  /** Provider route id. */
   provider: string
+  /** Display name for the card title. */
+  displayName: string
+  /** Hide the title row (the add card renders its own provider select). */
+  hideTitle?: boolean
   /** The owning namespace view (schema, layers, secrets). */
   namespace: SettingsNamespaceView
   /** Path from the section root to this provider's profile. */
@@ -35,15 +59,6 @@ export interface ProviderEditorProps {
   onClose: (changed: boolean) => void
 }
 
-/** Secrets re-rooted at the profile subtree (paths relative to the editor's form). */
-function secretsUnder(namespace: SettingsNamespaceView, path: readonly string[]): SchemaFormSecret[] {
-  return namespace.secrets.flatMap((secret) => {
-    if (secret.path.length < path.length) return []
-    if (!path.every((key, index) => secret.path[index] === key)) return []
-    return [{ path: secret.path.slice(path.length), set: secret.set }]
-  })
-}
-
 /** A user-section subtree as a plain draft object (absent → empty). */
 function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Record<string, unknown> {
   const subtree = getPath(namespace.user, path)
@@ -51,10 +66,16 @@ function draftAt(namespace: SettingsNamespaceView, path: readonly string[]): Rec
   return structuredClone(subtree) as Record<string, unknown>
 }
 
-/** Whether any key present in `before` is absent from `after` (a reset happened). */
-function removedAny(before: unknown, after: unknown): boolean {
+/**
+ * Whether any key present in `before` is absent from `after` (a reset
+ * happened somewhere in the draft, so the apply must replace, not merge).
+ * @param before - the user-layer subtree the draft started from.
+ * @param after - the edited draft.
+ * @returns whether a removal exists at any depth.
+ */
+export function removedAny(before: unknown, after: unknown): boolean {
   if (typeof before !== 'object' || before === null) return false
-  /* v8 ignore next -- the form edits containers in place; a container cannot become a primitive */
+  /* v8 ignore next -- the editor edits containers in place; a container cannot become a primitive */
   if (typeof after !== 'object' || after === null) return true
   for (const [key, value] of Object.entries(before)) {
     if (!(key in (after as Record<string, unknown>))) return true
@@ -63,6 +84,22 @@ function removedAny(before: unknown, after: unknown): boolean {
   return false
 }
 
+/** The editor layout the owning namespace selects. */
+function layoutOf(ns: string): EditorLayout {
+  if (ns === 'llm-deepseek') return 'deepseek'
+  if (ns === 'llm-pi-ai') return 'pi-ai'
+  return 'unknown'
+}
+
+/** The credential reference this profile resolves keys through. */
+function refFor(namespace: SettingsNamespaceView, path: readonly string[], provider: string): string {
+  const profile = getPath(namespace.value, path)
+  const named = typeof profile === 'object' && profile !== null
+    ? (profile as { apiKeyEnv?: unknown }).apiKeyEnv
+    : undefined
+  return typeof named === 'string' && named.length > 0 ? named : deriveKeyRef(provider)
+}
+
 /**
  * Render one provider's editing card.
  * @param props - the addressed profile plus wire faces and copy.
@@ -71,79 +108,175 @@ function removedAny(before: unknown, after: unknown): boolean {
 export function ProviderEditor(props: ProviderEditorProps): ReactNode {
   const { namespace, settingsPath, api, t } = props
   const [draft, setDraft] = useState<Record<string, unknown>>(() => draftAt(namespace, settingsPath))
+  const [keyDraft, setKeyDraft] = useState('')
+  const [keyState, setKeyState] = useState<CredentialView | undefined>(undefined)
   const [busy, setBusy] = useState(false)
   const [failure, setFailure] = useState<string | undefined>(undefined)
   const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema])
   const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath])
-  const subtreeSchema = useMemo(() => node?.toJSON(), [node])
   const fallback = getPath(namespace.value, settingsPath)
-  const secrets = useMemo(() => secretsUnder(namespace, settingsPath), [namespace, settingsPath])
+  const disabled = props.readOnly || busy
+  const layout = layoutOf(namespace.ns)
+  const keyRef = refFor(namespace, settingsPath, props.provider)
+
+  useEffect(() => {
+    let stale = false
+    setKeyState(undefined)
+    void api.credentials.describe({ refs: [keyRef] }).then((response) => {
+      if (stale || !response.result.ok) return
+      setKeyState(response.result.value.credentials[keyRef])
+    })
+    return () => { stale = true }
+  }, [api.credentials, keyRef])
+
+  const stringAt = (source: unknown, key: string): string | undefined => {
+    const value = getPath(source, [key])
+    return typeof value === 'string' && value.length > 0 ? value : undefined
+  }
+  const setField = (key: string, next: string | undefined): void => {
+    setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
+  }
 
   const apply = async (): Promise<void> => {
     setBusy(true)
     setFailure(undefined)
     const ns = namespace.ns
     const original = getPath(namespace.user, settingsPath)
-    const needsReplace = removedAny(original, draft)
-    // Merge patches stay minimal (just this profile); a replace must carry
-    // the complete next user section because it lands wholesale.
-    const patch = settingsPath.length === 0 ? draft : setPath({}, [...settingsPath], draft)
-    /* v8 ignore next 3 -- a subtree apply implies the join served this namespace's user layer */
-    const nextSection = settingsPath.length === 0
-      ? draft
-      : setPath(structuredClone((namespace.user ?? {}) as Record<string, unknown>), [...settingsPath], draft)
-    /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
-    if (node !== undefined) {
-      const sectionError = settingsPath.length === 0 ? validateDraft(node, draft) : undefined
-      if (sectionError !== undefined) {
+    // The pi-ai profile must name the reference the key stores under, so a
+    // dormant add (or a legacy profile without one) records the derivation.
+    const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
+      && stringAt(fallback, 'apiKeyEnv') === undefined
+      ? setPath(draft, ['apiKeyEnv'], keyRef)
+      : draft
+    const settingsChanged = JSON.stringify(next) !== JSON.stringify(original ?? {})
+    if (settingsChanged) {
+      const needsReplace = removedAny(original, next)
+      // Merge patches stay minimal (just this profile); a replace must carry
+      // the complete next user section because it lands wholesale.
+      const patch = settingsPath.length === 0 ? next : setPath({}, [...settingsPath], next)
+      /* v8 ignore next 3 -- a subtree apply implies the join served this namespace's user layer */
+      const nextSection = settingsPath.length === 0
+        ? next
+        : setPath(structuredClone((namespace.user ?? {}) as Record<string, unknown>), [...settingsPath], next)
+      /* v8 ignore next -- apply is only reachable from the rendered card, which required a resolved node */
+      if (node !== undefined) {
+        const sectionError = settingsPath.length === 0 ? validateDraft(node, next) : undefined
+        if (sectionError !== undefined) {
+          setBusy(false)
+          setFailure(sectionError)
+          return
+        }
+      }
+      const response = needsReplace
+        ? await api.settings.replace({ ns, section: nextSection })
+        : await api.settings.update({ ns, patch })
+      if (!response.result.ok) {
         setBusy(false)
-        setFailure(sectionError)
+        setFailure(response.result.error.message)
         return
       }
     }
-    const response = needsReplace
-      ? await api.settings.replace({ ns, section: nextSection })
-      : await api.settings.update({ ns, patch })
-    setBusy(false)
-    if (!response.result.ok) {
-      setFailure(response.result.error.message)
-      return
+    if (keyDraft.length > 0) {
+      const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
+      if (!stored.result.ok) {
+        setBusy(false)
+        setFailure(stored.result.error.message)
+        return
+      }
+      setKeyDraft('')
     }
+    setBusy(false)
     props.onClose(true)
   }
 
-  if (node === undefined || subtreeSchema === undefined) {
+  if (node === undefined) {
     // A directory entry addressing a position its schema cannot resolve is a
     // host-side inconsistency; showing it beats a blank card.
     return <p className={styles['error']}>{`${props.provider}: unresolvable settings path`}</p>
   }
 
+  const keyLocked = keyState?.writable === false
+  const effortField = layout === 'unknown' ? undefined : EFFORT_FIELD[layout]
+
   return (
     <div className={styles['editor']}>
-      <div className={styles['editorHeader']}>
-        <span className={styles['editorTitle']}>{props.provider}</span>
-      </div>
-      <SchemaForm
-        schema={subtreeSchema}
-        draft={draft}
-        fallback={fallback}
-        secrets={secrets}
-        disabled={props.readOnly || busy}
-        onChange={setDraft}
-        labels={{
-          reset: t('reset'),
-          add: t('addLabel'),
-          remove: t('removeLabel'),
-          secretSet: t('secretSet'),
-          secretUnset: t('secretUnset'),
-          inherited: t('inherited'),
-          unsupported: t('unsupported'),
-        }}
-        renderField={(context) => {
-          if (context.role !== 'credential-ref') return undefined
-          return <CredentialControl context={context} credentials={api.credentials} t={t} />
-        }}
-      />
+      {props.hideTitle === true
+        ? null
+        : (
+          <div className={styles['editorHeader']}>
+            <span className={styles['editorTitle']}>{props.displayName}</span>
+            {props.provider !== props.displayName
+              ? <span className={styles['editorRoute']}>{props.provider}</span>
+              : null}
+          </div>
+        )}
+      {layout === 'unknown'
+        ? <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
+        : (
+          <>
+            <div className={styles['field']}>
+              <span className={styles['fieldLabel']}>{t('keyInput')}</span>
+              <input
+                className={styles['input']}
+                type="password"
+                autoComplete="off"
+                value={keyDraft}
+                placeholder={keyLocked
+                  ? t('keyEnvLocked')
+                  : keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')}
+                aria-label={t('keyInput')}
+                disabled={disabled || keyLocked}
+                onChange={(event) => { setKeyDraft(event.target.value) }}
+              />
+            </div>
+            <details className={styles['customized']}>
+              <summary className={styles['customizedSummary']}>{t('customized')}</summary>
+              <div className={styles['customizedBody']}>
+                {layout === 'deepseek'
+                  ? (
+                    <div className={styles['field']}>
+                      <span className={styles['fieldLabel']}>{t('baseUrl')}</span>
+                      <input
+                        className={styles['input']}
+                        type="text"
+                        value={stringAt(draft, 'baseURL') ?? ''}
+                        placeholder={stringAt(fallback, 'baseURL') ?? t('baseUrlDefault')}
+                        aria-label={t('baseUrl')}
+                        disabled={disabled}
+                        onChange={(event) => {
+                          setField('baseURL', event.target.value === '' ? undefined : event.target.value)
+                        }}
+                      />
+                    </div>
+                  )
+                  : null}
+                {/* v8 ignore next -- EFFORT_FIELD is total over non-unknown layouts; the check only narrows the type */}
+                {effortField !== undefined
+                  ? (
+                    <div className={styles['field']}>
+                      <span className={styles['fieldLabel']}>{t('effort')}</span>
+                      <select
+                        className={styles['input']}
+                        value={stringAt(draft, effortField) ?? ''}
+                        aria-label={t('effort')}
+                        disabled={disabled}
+                        onChange={(event) => {
+                          setField(effortField, event.target.value === '' ? undefined : event.target.value)
+                        }}
+                      >
+                        <option value="">{t('effortInherit')}</option>
+                        {EFFORT_CHOICES[layout].map(choice => (
+                          <option key={choice} value={choice}>{choice}</option>
+                        ))}
+                      </select>
+                    </div>
+                  )
+                  : null}
+                <p className={styles['advancedHint']}>{`${t('advancedHint')} (${namespace.ns})`}</p>
+              </div>
+            </details>
+          </>
+        )}
       {failure !== undefined ? <p className={styles['error']}>{failure}</p> : null}
       <div className={styles['editorActions']}>
         <button
@@ -157,7 +290,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
         <button
           type="button"
           className={styles['primaryButton']}
-          disabled={props.readOnly || busy}
+          disabled={disabled || layout === 'unknown'}
           onClick={() => { void apply() }}
         >
           {busy ? t('applying') : t('apply')}

+ 18 - 30
packages/client/ui-models/src/client/locales.ts

@@ -7,7 +7,6 @@ export const en = {
   intro: 'Enter your API keys to use models from the following providers.',
   active: 'Active',
   dormant: 'Inactive',
-  keyMissing: 'No API key',
   edit: 'Edit',
   remove: 'Delete',
   add: 'Add provider',
@@ -18,21 +17,16 @@ export const en = {
   readOnly: 'The settings document is read-only in this deployment.',
   loadFailed: 'Loading the provider directory failed',
   retry: 'Retry',
-  credentialRef: 'API key environment variable',
-  credentialConfigured: 'Configured',
-  credentialFromEnv: 'from the launch environment (read-only)',
-  credentialMissing: 'Not configured',
   keyInput: 'API key',
-  keyPlaceholder: 'Enter a key to store it',
-  keySave: 'Save key',
-  keyClear: 'Clear key',
-  reset: 'Reset',
-  addLabel: 'Add',
-  removeLabel: 'Remove',
-  secretSet: 'Configured — enter a new value to replace',
-  secretUnset: 'Not configured',
-  inherited: 'Default',
-  unsupported: 'This field has no form control; edit the settings document directly.',
+  keyPlaceholder: 'Enter your API key',
+  keyStored: 'Configured — enter a new value to replace',
+  keyEnvLocked: 'Provided by the launch environment (read-only)',
+  customized: 'Customized settings',
+  baseUrl: 'Base URL',
+  baseUrlDefault: 'Provider default',
+  effort: 'Reasoning effort',
+  effortInherit: 'Default',
+  advancedHint: 'Other fields live in settings.yaml; edit that section directly.',
 }
 
 /** Chinese strings (same keys as {@link en}). */
@@ -42,7 +36,6 @@ export const zh: typeof en = {
   intro: '填入各提供方的 API 密钥即可使用其模型。',
   active: '已启用',
   dormant: '未启用',
-  keyMissing: '缺少密钥',
   edit: '编辑',
   remove: '删除',
   add: '添加提供方',
@@ -53,19 +46,14 @@ export const zh: typeof en = {
   readOnly: '当前部署的设置文档为只读。',
   loadFailed: '加载提供方目录失败',
   retry: '重试',
-  credentialRef: 'API 密钥环境变量',
-  credentialConfigured: '已配置',
-  credentialFromEnv: '来自启动环境(只读)',
-  credentialMissing: '未配置',
   keyInput: 'API 密钥',
-  keyPlaceholder: '输入密钥以保存',
-  keySave: '保存密钥',
-  keyClear: '清除密钥',
-  reset: '重置',
-  addLabel: '添加',
-  removeLabel: '移除',
-  secretSet: '已设置——输入新值可替换',
-  secretUnset: '未设置',
-  inherited: '默认',
-  unsupported: '该字段没有对应表单控件;请直接编辑设置文档。',
+  keyPlaceholder: '输入 API 密钥',
+  keyStored: '已配置——输入新值可替换',
+  keyEnvLocked: '由启动环境提供(只读)',
+  customized: '自定义设置',
+  baseUrl: 'API 地址',
+  baseUrlDefault: '提供方默认',
+  effort: '推理强度',
+  effortInherit: '默认',
+  advancedHint: '其余字段在 settings.yaml 中,请直接编辑对应段。',
 }

+ 11 - 0
packages/client/ui-models/src/client/store.ts

@@ -40,6 +40,17 @@ export interface ModelsSettingsState {
   namespaces: ReadonlyMap<string, SettingsNamespaceView>
 }
 
+/**
+ * Derive the conventional credential reference for a provider route: the v1
+ * page never asks for an environment-variable name, so a typed key stores
+ * under this derived reference and the profile records it as `apiKeyEnv`.
+ * @param provider - provider route id (e.g. `anthropic`, `minimax-cn`).
+ * @returns the derived reference name (e.g. `MINIMAX_CN_API_KEY`).
+ */
+export function deriveKeyRef(provider: string): string {
+  return `${provider.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`
+}
+
 /** The credential reference a resolved profile names (its `apiKeyEnv` field). */
 function apiKeyEnvOf(namespace: SettingsNamespaceView | undefined, path: readonly string[]): string | undefined {
   if (namespace === undefined) return undefined

+ 234 - 154
packages/client/ui-models/tests/components.spec.tsx

@@ -1,13 +1,15 @@
 // @vitest-environment jsdom
-/** Section, editor, and credential-control behavior over a scripted wire face. */
+/** Section, setup-card, and hand-written editor behavior over a scripted wire face. */
 import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import Schema from 'schemastery'
 import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
 import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
-import { ModelsSection, removeProviderProfile } from '../src/client/ModelsSection.tsx'
+import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx'
 import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
-import { ModelsSettingsStore } from '../src/client/store.ts'
+import { removedAny } from '../src/client/ProviderEditor.tsx'
+import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
+import type { ProviderRow } from '../src/client/store.ts'
 import { en } from '../src/client/locales.ts'
 
 afterEach(cleanup)
@@ -20,6 +22,7 @@ const PiAiConfig = Schema.object({
     apiKey: Schema.string().role('secret'),
     apiKeyEnv: Schema.string().role('credential-ref'),
     baseURL: Schema.string(),
+    reasoning: Schema.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
     headers: Schema.dict(Schema.string()),
   })),
 })
@@ -27,8 +30,8 @@ const PiAiConfig = Schema.object({
 const DeepSeekConfig = Schema.object({
   apiKey: Schema.string().role('secret'),
   apiKeyEnv: Schema.string().role('credential-ref'),
-  baseURL: Schema.string(),
-  label: Schema.string().required(),
+  baseURL: Schema.string().pattern(/^https:\/\//),
+  reasoningEffort: Schema.union(['off', 'high', 'max']),
 })
 
 function wireNamespaces(): SettingsNamespaceView[] {
@@ -36,11 +39,21 @@ function wireNamespaces(): SettingsNamespaceView[] {
     {
       ns: 'llm-deepseek',
       schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
-      value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base' },
-      base: { baseURL: 'https://base' },
+      value: { apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: 'https://base', reasoningEffort: 'high' },
+      base: {},
+      user: { reasoningEffort: 'high' },
       applies: 'live',
       secrets: [{ path: ['apiKey'], set: false }],
     },
+    {
+      ns: 'llm-plain',
+      schema: JSON.parse(JSON.stringify(Schema.object({
+        profiles: Schema.dict(Schema.object({ note: Schema.string() })),
+      }).toJSON())) as unknown,
+      value: {},
+      applies: 'live',
+      secrets: [],
+    },
     {
       ns: 'llm-pi-ai',
       schema: JSON.parse(JSON.stringify(PiAiConfig.toJSON())) as unknown,
@@ -68,8 +81,8 @@ function scriptedFace(overrides: {
   replace?: ReturnType<typeof vi.fn>
   set?: ReturnType<typeof vi.fn>
 } = {}) {
-  const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[1])))
-  const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[1])))
+  const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
+  const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2])))
   const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({})))
   const face = {
     llm: {
@@ -80,6 +93,7 @@ function scriptedFace(overrides: {
           { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
           { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
           { provider: 'broken', displayName: 'broken', settingsNs: 'llm-pi-ai', settingsPath: ['nope', 'x'], active: false },
+          { provider: 'plain', displayName: 'plain', settingsNs: 'llm-plain', settingsPath: ['profiles', 'plain'], active: false },
         ],
       }))),
       models: vi.fn(() => Promise.resolve(ok({ groups: [], failures: [] }))),
@@ -121,157 +135,249 @@ async function mountSection(overrides: Parameters<typeof scriptedFace>[0] = {})
 }
 
 describe('ModelsSection', () => {
-  it('renders configured rows with status badges and the add vocabulary', async () => {
+  it('renders the unkeyed whole-section provider as an open setup card beside the rows', async () => {
     await mountSection()
+    // DeepSeek has no configured credential and no stored apiKey → setup card.
     expect(screen.getByText('DeepSeek')).toBeTruthy()
+    expect(screen.getByLabelText(en.keyInput)).toBeTruthy()
+    // Configured pi-ai profiles render as rows with liveness badges only.
     expect(screen.getByText('openai')).toBeTruthy()
-    expect(screen.queryByText('anthropic', { selector: 'span' })).toBeNull()
-    expect(screen.getAllByText(en.active)).toHaveLength(2)
-    // A configured profile whose route did not register renders dormant.
+    expect(screen.getAllByText(en.active)).toHaveLength(1)
     expect(screen.getByText(en.dormant)).toBeTruthy()
-    expect(screen.getByText(en.keyMissing)).toBeTruthy()
-    const add = screen.getByLabelText<HTMLSelectElement>(en.add)
-    expect([...add.options].map(option => option.value)).toEqual(['', 'anthropic', 'broken'])
-    expect(screen.getAllByText(en.remove)).toHaveLength(2)
+    expect(screen.getByText(`+ ${en.add}`)).toBeTruthy()
+  })
+
+  it('turns the setup card into a row once the credential reports configured', async () => {
+    const { face } = await mountSection()
+    face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
+      credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: true, writable: true }])),
+    })))
+    const controller = new ModelsSettingsStore(face as unknown as WireFace)
+    await controller.load()
+    cleanup()
+    render(<ModelsSection
+      controller={controller}
+      useSnapshot={bindSnapshotSelector(controller.store)}
+      api={face as never}
+      t={t}
+    />)
+    // Now a row with an Edit button, not an open card.
+    expect(screen.getAllByText(en.edit).length).toBeGreaterThan(1)
+    expect(screen.queryByLabelText(en.keyInput)).toBeNull()
+  })
+
+  it('decides setup need from the credential state and the stored apiKey slot', () => {
+    const namespace = wireNamespaces()[0] as SettingsNamespaceView
+    const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
+    const row = (credential: ProviderRow['credential']): ProviderRow =>
+      ({ entry, configured: true, removable: false, apiKeyEnv: 'X', credential })
+    expect(needsSetup(row(undefined), namespace)).toBe(true)
+    expect(needsSetup(row({ configured: true, writable: true }), namespace)).toBe(false)
+    const stored: SettingsNamespaceView = { ...namespace, secrets: [{ path: ['apiKey'], set: true }] }
+    expect(needsSetup(row(undefined), stored)).toBe(false)
+    const nested = { ...row(undefined), entry: { ...entry, settingsPath: ['providers', 'x'] } }
+    expect(needsSetup(nested, namespace)).toBe(false)
+  })
+
+  it('derives conventional credential references from route ids', () => {
+    expect(deriveKeyRef('anthropic')).toBe('ANTHROPIC_API_KEY')
+    expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY')
+  })
+
+  it('detects removals at any draft depth', () => {
+    expect(removedAny({ a: { b: 1, c: 2 } }, { a: { b: 1 } })).toBe(true)
+    expect(removedAny({ a: { b: 1 } }, { a: { b: 2 }, d: 3 })).toBe(false)
+    expect(removedAny(undefined, {})).toBe(false)
+  })
+
+  it('stores a typed key write-only from the setup card without touching settings', async () => {
+    const { set, update, face } = await mountSection()
+    const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
+    fireEvent.change(key, { target: { value: 'sk-live' } })
+    fireEvent.click(screen.getByText(en.apply))
+    await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
+    expect(update).not.toHaveBeenCalled()
+    await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
   })
 
-  it('opens the editor, applies an edit as a merge patch, and reloads', async () => {
-    const { update, face } = await mountSection()
-    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
-    const baseURL = await screen.findByDisplayValue('https://proxy')
-    fireEvent.change(baseURL, { target: { value: 'https://next' } })
+  it('applies customized deepseek fields as a merge patch', async () => {
+    const { update } = await mountSection({
+      update: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
+    })
+    fireEvent.click(screen.getByText(en.customized))
+    const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
+    expect(baseURL.placeholder).toBe('https://base')
+    fireEvent.change(baseURL, { target: { value: 'https://next2' } })
     fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
     expect(update.mock.calls[0]?.[0]).toEqual({
-      ns: 'llm-pi-ai',
-      patch: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://next', headers: { 'X-Team': 'a' } } } },
+      ns: 'llm-deepseek',
+      patch: { reasoningEffort: 'high', baseURL: 'https://next2' },
     })
-    await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) })
   })
 
-  it('applies a field reset through replace so the removal lands', async () => {
+  it('clears an inherited override through replace so the removal lands', async () => {
     const { replace, update } = await mountSection()
-    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
-    const baseURL = await screen.findByDisplayValue('https://proxy')
-    fireEvent.change(baseURL, { target: { value: '' } })
+    fireEvent.click(screen.getByText(en.customized))
+    const effort = screen.getByLabelText<HTMLSelectElement>(en.effort)
+    expect(effort.value).toBe('high')
+    fireEvent.change(effort, { target: { value: '' } })
     fireEvent.click(screen.getByText(en.apply))
     await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
     expect(update).not.toHaveBeenCalled()
-    expect(replace.mock.calls[0]?.[0]).toEqual({
-      ns: 'llm-pi-ai',
-      section: { providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY', headers: { 'X-Team': 'a' } }, zombie: {} } },
-    })
+    expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} })
   })
 
-  it('lands a nested removal (dict entry) through replace', async () => {
-    const { replace } = await mountSection()
-    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
-    await screen.findByDisplayValue('https://proxy')
-    // Row deletion says "Delete"; the only "Remove" inside the open editor
-    // is schema-form's headers-dict row control.
-    fireEvent.click(screen.getAllByText(en.removeLabel)[0] as HTMLElement)
-    fireEvent.click(screen.getByText(en.apply))
-    await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
-    const section = (replace.mock.calls[0]?.[0] as { section: { providers: { openai: { headers?: unknown } } } }).section
-    expect(section.providers.openai.headers).toEqual({})
+  it('falls back to the provider-default placeholder and clears typed input back to inherited', async () => {
+    const { face } = scriptedFace()
+    const bare: SettingsNamespaceView = {
+      ns: 'llm-deepseek',
+      schema: JSON.parse(JSON.stringify(DeepSeekConfig.toJSON())) as unknown,
+      value: {},
+      applies: 'live',
+      secrets: [],
+    }
+    const { ProviderEditor } = await import('../src/client/ProviderEditor.tsx')
+    render(<ProviderEditor
+      provider="deepseek-official"
+      displayName="DeepSeek"
+      namespace={bare}
+      settingsPath={[]}
+      api={face as never}
+      t={t}
+      readOnly={false}
+      onClose={() => {}}
+    />)
+    fireEvent.click(screen.getByText(en.customized))
+    const baseURL = screen.getByLabelText<HTMLInputElement>(en.baseUrl)
+    expect(baseURL.placeholder).toBe(en.baseUrlDefault)
+    fireEvent.change(baseURL, { target: { value: 'https://x' } })
+    expect(baseURL.value).toBe('https://x')
+    fireEvent.change(baseURL, { target: { value: '' } })
+    expect(baseURL.value).toBe('')
   })
 
-  it('surfaces a rejected apply inside the editor', async () => {
-    const { update } = await mountSection({
-      update: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
-    })
-    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
-    const baseURL = await screen.findByDisplayValue('https://proxy')
-    fireEvent.change(baseURL, { target: { value: 'https://next' } })
+  it('rejects an invalid draft before writing', async () => {
+    const { update } = await mountSection()
+    fireEvent.click(screen.getByText(en.customized))
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'not-a-url' } })
     fireEvent.click(screen.getByText(en.apply))
-    await screen.findByText('llm-pi-ai: unknown pi-ai provider "bogus"')
-    expect(update).toHaveBeenCalledTimes(1)
+    await screen.findByText(/baseURL/)
+    expect(update).not.toHaveBeenCalled()
   })
 
-  it('adds a dormant provider through the add select and merges its profile in', async () => {
+  it('edits a pi-ai profile with the curated fields only', async () => {
     const { update } = await mountSection()
-    fireEvent.change(screen.getByLabelText(en.add), { target: { value: 'anthropic' } })
-    const ref = await screen.findByLabelText<HTMLInputElement>(en.credentialRef)
-    // No reference yet, so the write-only key input stays hidden until one exists.
-    expect(screen.queryByLabelText(en.keyInput)).toBeNull()
-    fireEvent.change(ref, { target: { value: 'ANTHROPIC_API_KEY' } })
-    const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
-    expect(key.placeholder).toBe(en.keyPlaceholder)
-    fireEvent.click(screen.getByText(en.apply))
+    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
+    // The configured credential shows as the stored placeholder.
+    const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
+    const editorKey = keys[keys.length - 1] as HTMLInputElement
+    await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyStored) })
+    // No Base URL for pi-ai; the only one on the page is the setup card's.
+    fireEvent.click(screen.getAllByText(en.customized)[1] as HTMLElement)
+    expect(screen.getAllByLabelText(en.baseUrl)).toHaveLength(1)
+    const effort = screen.getAllByLabelText<HTMLSelectElement>(en.effort)
+    fireEvent.change(effort[effort.length - 1] as HTMLSelectElement, { target: { value: 'xhigh' } })
+    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
     await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
     expect(update.mock.calls[0]?.[0]).toEqual({
       ns: 'llm-pi-ai',
-      patch: { providers: { anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' } } },
+      patch: {
+        providers: {
+          openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy', headers: { 'X-Team': 'a' }, reasoning: 'xhigh' },
+        },
+      },
     })
   })
 
-  it('removes a user-added provider through replace', async () => {
-    const { replace } = await mountSection()
-    fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
-    await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
-    expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', section: { providers: { zombie: {} } } })
+  it('adds a dormant provider with a derived reference and stores its key', async () => {
+    const { update, set } = await mountSection()
+    fireEvent.click(screen.getByText(`+ ${en.add}`))
+    const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
+    expect([...pick.options].map(option => option.value)).toEqual(['anthropic', 'broken', 'plain'])
+    expect(pick.value).toBe('anthropic')
+    const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
+    const addKey = keys[keys.length - 1] as HTMLInputElement
+    fireEvent.change(addKey, { target: { value: 'sk-ant' } })
+    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
+    expect(update.mock.calls[0]?.[0]).toEqual({
+      ns: 'llm-pi-ai',
+      patch: { providers: { anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' } } },
+    })
+    await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) })
   })
 
-  it('reports an unresolvable settings path instead of a blank editor', async () => {
+  it('switches the add card target and degrades unknown or broken targets loudly', async () => {
     await mountSection()
-    fireEvent.change(screen.getByLabelText(en.add), { target: { value: 'broken' } })
+    fireEvent.click(screen.getByText(`+ ${en.add}`))
+    const pick = await screen.findByLabelText<HTMLSelectElement>(en.provider)
+    fireEvent.change(pick, { target: { value: 'broken' } })
     await screen.findByText(/unresolvable settings path/)
+    fireEvent.change(pick, { target: { value: 'plain' } })
+    await waitFor(() => {
+      expect(screen.getAllByText(content => content.includes(en.advancedHint)).length).toBeGreaterThan(0)
+    })
+    // The hint-only card cannot apply anything.
+    const applies = screen.getAllByText<HTMLButtonElement>(en.apply)
+    expect((applies[applies.length - 1] as HTMLButtonElement).disabled).toBe(true)
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
   })
 
-  it('clears the credential reference back to inherited from the control', async () => {
-    const { update } = await mountSection()
-    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
-    const ref = await screen.findByLabelText<HTMLInputElement>(en.credentialRef)
-    expect(ref.value).toBe('OPENAI_API_KEY')
-    fireEvent.change(ref, { target: { value: '' } })
+  it('surfaces a rejected settings write and never stores the key after it', async () => {
+    const { set } = await mountSection({
+      update: vi.fn(() => Promise.resolve(fail('llm-pi-ai: unknown pi-ai provider "bogus"'))),
+    })
+    fireEvent.click(screen.getByText(`+ ${en.add}`))
+    await screen.findByLabelText(en.provider)
+    const keys = screen.getAllByLabelText<HTMLInputElement>(en.keyInput)
+    fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-x' } })
+    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
+    await screen.findByText(/unknown pi-ai provider/)
+    expect(set).not.toHaveBeenCalled()
+  })
+
+  it('surfaces a shadowed credential write on the card', async () => {
+    await mountSection({
+      set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
+    })
+    const key = screen.getByLabelText<HTMLInputElement>(en.keyInput)
+    fireEvent.change(key, { target: { value: 'sk-live' } })
     fireEvent.click(screen.getByText(en.apply))
-    await waitFor(() => { expect(update).toHaveBeenCalledTimes(0) })
-    // Dropping the reference is a removal, so it lands through replace.
+    await screen.findByText(/shadowed by the read-only environment/)
   })
 
-  it('shows the env-shadowed credential badge and hides the key input', async () => {
+  it('locks the key input when the launch environment provides the credential', async () => {
     const { face } = await mountSection()
-    face.credentials.describe.mockImplementation(() => Promise.resolve(ok({
-      credentials: { OPENAI_API_KEY: { configured: true, source: 'env', writable: false } },
+    face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({
+      credentials: Object.fromEntries(payload.refs.map(ref => [ref, {
+        configured: ref === 'OPENAI_API_KEY', source: 'env', writable: false,
+      }])),
     })))
-    fireEvent.click(screen.getAllByText(en.edit)[1] as HTMLElement)
-    await screen.findByText(content => content.includes(en.credentialFromEnv))
-    expect(screen.queryByLabelText(en.keyInput)).toBeNull()
+    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
+    const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
+    const editorKey = keys[keys.length - 1] as HTMLInputElement
+    await waitFor(() => { expect(editorKey.placeholder).toBe(en.keyEnvLocked) })
+    expect(editorKey.disabled).toBe(true)
   })
 
-  it('renders no badge while the credential domain fails, and keeps a failed post-save describe quiet', async () => {
+  it('keeps a failed credential describe silent and the input usable', async () => {
     const { face, set } = await mountSection()
     face.credentials.describe.mockImplementation(() => Promise.resolve(fail('down', 'internal')) as never)
     fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
-    const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
-    expect(screen.queryByText(en.credentialConfigured)).toBeNull()
-    expect(screen.queryByText(en.credentialMissing)).toBeNull()
-    fireEvent.change(key, { target: { value: 'sk-live' } })
-    fireEvent.click(screen.getByText(en.keySave))
+    const keys = await screen.findAllByLabelText<HTMLInputElement>(en.keyInput)
+    const editorKey = keys[keys.length - 1] as HTMLInputElement
+    expect(editorKey.placeholder).toBe(en.keyPlaceholder)
+    fireEvent.change(editorKey, { target: { value: 'sk-live' } })
+    fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement)
     await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) })
-    expect(key).toBeTruthy()
   })
 
-  it('stores a credential value write-only and refreshes its badge', async () => {
-    const { set, face } = await mountSection()
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
-    const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
-    fireEvent.change(key, { target: { value: 'sk-live' } })
-    fireEvent.click(screen.getByText(en.keySave))
-    await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) })
-    await waitFor(() => { expect(face.credentials.describe.mock.calls.length).toBeGreaterThan(1) })
-    expect(key.value).toBe('')
-  })
-
-  it('surfaces a shadowed credential write on the control', async () => {
-    await mountSection({
-      set: vi.fn(() => Promise.resolve(fail('credentials: DEEPSEEK_API_KEY is shadowed by the read-only environment', 'credential-rejected'))),
-    })
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
-    const key = await screen.findByLabelText<HTMLInputElement>(en.keyInput)
-    fireEvent.change(key, { target: { value: 'sk-live' } })
-    fireEvent.click(screen.getByText(en.keySave))
-    await screen.findByText(/shadowed by the read-only environment/)
+  it('removes a user-added provider through replace', async () => {
+    const { replace } = await mountSection()
+    fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement)
+    await waitFor(() => { expect(replace).toHaveBeenCalledTimes(1) })
+    expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', section: { providers: { zombie: {} } } })
   })
 
   it('renders the load failure with a retry control', async () => {
@@ -307,56 +413,30 @@ describe('ModelsSection', () => {
     />)
     expect(screen.getByText(en.readOnly)).toBeTruthy()
     expect(screen.getAllByText<HTMLButtonElement>(en.remove).every(button => button.disabled)).toBe(true)
+    expect(screen.getByText<HTMLButtonElement>(`+ ${en.add}`).disabled).toBe(true)
   })
 
-  it('toggles the editor closed on a second edit click and on cancel', async () => {
+  it('toggles the row editor closed on a second edit click and on cancel', async () => {
     const { update } = await mountSection()
-    const edit = screen.getAllByText(en.edit)[1] as HTMLElement
+    const edit = screen.getAllByText(en.edit)[0] as HTMLElement
     fireEvent.click(edit)
-    await screen.findByDisplayValue('https://proxy')
+    await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
     fireEvent.click(edit)
-    expect(screen.queryByDisplayValue('https://proxy')).toBeNull()
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
     fireEvent.click(edit)
-    await screen.findByDisplayValue('https://proxy')
-    fireEvent.click(screen.getByText(en.cancel))
-    expect(screen.queryByDisplayValue('https://proxy')).toBeNull()
+    await waitFor(() => { expect(screen.getAllByLabelText(en.keyInput).length).toBe(2) })
+    fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
+    expect(screen.getAllByLabelText(en.keyInput)).toHaveLength(1)
     expect(update).not.toHaveBeenCalled()
   })
 
-  it('ignores the placeholder option of the add select', async () => {
+  it('cancels the add card back to the add button', async () => {
     await mountSection()
-    fireEvent.change(screen.getByLabelText(en.add), { target: { value: '' } })
-    expect(screen.queryByText(en.apply)).toBeNull()
-  })
-
-  it('applies a whole-section namespace (path []) as a direct patch', async () => {
-    const { update } = await mountSection({
-      update: vi.fn(() => Promise.resolve(ok(wireNamespaces()[0]))),
-    })
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
-    await screen.findByLabelText(en.credentialRef)
-    const label = screen.getByPlaceholderText<HTMLInputElement>(/label|Default/i) ?? undefined
-    const labelInput = screen.getAllByRole('textbox').find(input =>
-      (input as HTMLInputElement).type === 'text'
-      && input.closest('div')?.previousElementSibling?.textContent?.includes('label') === true)
-    const target = labelInput ?? screen.getAllByRole('textbox').at(-1)
-    fireEvent.change(target as Element, { target: { value: 'Mine' } })
-    fireEvent.click(screen.getByText(en.apply))
-    await waitFor(() => { expect(update).toHaveBeenCalledTimes(1) })
-    const payload = update.mock.calls[0]?.[0] as { ns: string; patch: Record<string, unknown> }
-    expect(payload.ns).toBe('llm-deepseek')
-    expect(payload.patch['label']).toBe('Mine')
-    expect(label ?? true).toBeTruthy()
-  })
-
-  it('rejects a section-level invalid draft before writing', async () => {
-    const { update } = await mountSection()
-    fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement)
-    await screen.findByLabelText(en.credentialRef)
-    fireEvent.click(screen.getByText(en.apply))
-    // schemastery names the missing required field in its failure text.
-    await screen.findByText(/required/)
-    expect(update).not.toHaveBeenCalled()
+    fireEvent.click(screen.getByText(`+ ${en.add}`))
+    await screen.findByLabelText(en.provider)
+    fireEvent.click(screen.getAllByText(en.cancel)[1] as HTMLElement)
+    await screen.findByText(`+ ${en.add}`)
+    expect(screen.queryByLabelText(en.provider)).toBeNull()
   })
 
   it('loads on first render of an idle controller', async () => {
@@ -373,14 +453,14 @@ describe('ModelsSection', () => {
 
   it('removes against a namespace with no user layer as an empty-section replace', async () => {
     const { face, replace, controller } = await mountSection()
-    const namespace = controller.store.getSnapshot().namespaces.get('llm-deepseek')
+    const namespace = controller.store.getSnapshot().namespaces.get('llm-plain')
     await removeProviderProfile(
       face as unknown as Parameters<typeof removeProviderProfile>[0],
       controller,
-      { settingsNs: 'llm-deepseek', settingsPath: ['ghost-profile'] },
+      { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] },
       namespace as NonNullable<typeof namespace>,
     )
-    expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-deepseek', section: {} })
+    expect(replace.mock.calls[0]?.[0]).toEqual({ ns: 'llm-plain', section: {} })
   })
 
   it('keeps the snapshot untouched when a removal write is refused', async () => {

+ 0 - 6
pnpm-lock.yaml

@@ -956,9 +956,6 @@ importers:
 
   packages/client/schema-form:
     dependencies:
-      react:
-        specifier: ^18.2.0
-        version: 18.3.1
       schemastery:
         specifier: ^3.18.0
         version: 3.18.0
@@ -966,9 +963,6 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../support/invariants
-      '@types/react':
-        specifier: ~18.3.1
-        version: 18.3.31
       cordis:
         specifier: ^4.0.0-rc.7
         version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5)