فهرست منبع

fix(llm-pi-ai): keep catalog-invalid settings repairable

Yichen Jiang 1 ماه پیش
والد
کامیت
fe03b0686d
50فایلهای تغییر یافته به همراه655 افزوده شده و 157 حذف شده
  1. 2 2
      .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml
  2. 1 1
      .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
  3. 1 1
      .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md
  4. 6 0
      .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml
  5. 39 0
      .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md
  6. 39 0
      .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md
  7. 37 0
      apps/web/tests/expected/models-settings-recovery/stored-error.expected.md
  8. 89 0
      apps/web/tests/models-settings-recovery.e2e.ts
  9. 1 0
      apps/web/tsconfig.json
  10. 2 2
      docs/config-catalog.i18n.yaml
  11. 1 1
      docs/config-catalog.md
  12. 1 1
      docs/config-catalog.zh.md
  13. 2 2
      docs/subsystems/llm-streaming.i18n.yaml
  14. 2 0
      docs/subsystems/llm-streaming.md
  15. 2 0
      docs/subsystems/llm-streaming.zh.md
  16. 2 2
      docs/subsystems/settings.i18n.yaml
  17. 8 0
      docs/subsystems/settings.md
  18. 8 0
      docs/subsystems/settings.zh.md
  19. 2 2
      packages/client/ui-settings-models/README.i18n.yaml
  20. 2 0
      packages/client/ui-settings-models/README.md
  21. 2 0
      packages/client/ui-settings-models/README.zh.md
  22. 42 32
      packages/client/ui-settings-models/src/client/ModelsSection.tsx
  23. 15 8
      packages/client/ui-settings-models/src/client/store.ts
  24. 35 0
      packages/client/ui-settings-models/tests/components.client.spec.tsx
  25. 9 1
      packages/client/ui-settings-models/tests/store.client.spec.ts
  26. 3 3
      packages/extensions/tool-cordis/src/api-catalog.ts
  27. 2 2
      packages/llm/llm-pi-ai/README.i18n.yaml
  28. 2 0
      packages/llm/llm-pi-ai/README.md
  29. 2 0
      packages/llm/llm-pi-ai/README.zh.md
  30. 13 3
      packages/llm/llm-pi-ai/src/adapter.ts
  31. 39 15
      packages/llm/llm-pi-ai/src/catalog.ts
  32. 66 43
      packages/llm/llm-pi-ai/src/config.ts
  33. 10 11
      packages/llm/llm-pi-ai/src/index.ts
  34. 2 2
      packages/llm/llm-pi-ai/src/provider.ts
  35. 25 13
      packages/llm/llm-pi-ai/tests/catalog.spec.ts
  36. 1 1
      packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts
  37. 10 1
      packages/llm/llm-pi-ai/tests/config.spec.ts
  38. 63 1
      packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts
  39. 2 2
      packages/llm/llm/README.i18n.yaml
  40. 1 1
      packages/llm/llm/README.md
  41. 1 1
      packages/llm/llm/README.zh.md
  42. 4 1
      packages/llm/llm/src/index.ts
  43. 2 0
      packages/llm/llm/src/types.ts
  44. 1 0
      packages/llm/llm/tests/service.spec.ts
  45. 2 2
      packages/settings/settings/README.i18n.yaml
  46. 2 0
      packages/settings/settings/README.md
  47. 2 0
      packages/settings/settings/README.zh.md
  48. 20 0
      packages/settings/settings/src/index.ts
  49. 29 0
      packages/settings/settings/tests/settings.spec.ts
  50. 1 0
      tsconfig.host.json

+ 2 - 2
.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md
-2026-07-14-provider-routed-llm-adapters.md: 4cc3cc3cdceeefdfea864ccf3465e528e32159c7
-2026-07-14-provider-routed-llm-adapters.zh.md: 098a6af7c951c6dee8a1b4f948bc69725e8d189c
+2026-07-14-provider-routed-llm-adapters.md: 24d6e7e439dc74a158801b647ddac96169730af1
+2026-07-14-provider-routed-llm-adapters.zh.md: f740468ed67830dabf5769eca206402d91c90aac

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md

@@ -30,7 +30,7 @@ A provider has exactly one adapter owner in a Cordis context. `dsh-llm-deepseek`
 
 `dsh-llm-pi-ai` takes one non-empty list of provider profiles. Provider names must be unique within the list and present in pi-ai's `getProviders()` result. Each profile contains the provider name plus optional `apiKey`, `baseURL`, headers, reasoning level and budgets, cache retention, transport, SDK timeouts, a Harness stream-idle timeout, and a provider-owned `retryPolicy`. The adapter forces pi-ai's `maxRetries` to zero so one `stream()` call makes one visible provider attempt, while `dsh-llm-retry` executes the resolved policy at the agent failed-step extension point. Credentials are never global: an explicit key applies only to its profile, while an absent key lets pi-ai resolve its standard environment variable, OAuth token, AWS credential chain, Google ADC, or other provider-native ambient authentication. An explicitly empty key is invalid configuration rather than an environment fallback.
 
-The plugin registers all configured provider names against one `PiAiAdapter` in one all-or-nothing call. A request uses its provider to select the matching profile and finds its model in `getModels(provider)` to obtain the catalog descriptor. An unknown provider fails at plugin load; an unknown model fails before network I/O with `UNKNOWN_MODEL`. The catalog object is never mutated. When a profile supplies `baseURL`, the adapter clones the selected descriptor and overrides only `baseUrl`, so a private endpoint can retain pi-ai's API, capabilities, compatibility flags, context limits, and reasoning map. The private endpoint must implement the selected provider's protocol, and the model id must still exist in the installed pi-ai catalog.
+The plugin registers configured provider names against one `PiAiAdapter` in one atomic call. Each immutable request snapshot combines the effective profiles and their serviceable model descriptors. Catalog-external models require an explicit or inferable protocol and endpoint. Stored catalog errors remain visible and repairable under the [settings catalog recovery decision](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md), while writes validate changed providers and requests reject the selected failed model before network I/O.
 
 The adapter calls pi-ai's `streamSimple()` so each catalog model chooses its registered API implementation, including OpenAI Responses instead of Chat Completions where the descriptor says `openai-responses`. Harness temperature, maximum tokens, signal, session id, and the profile's common stream options flow through directly. Profile headers merge with the mandatory Harness attribution headers, with Harness attribution winning its reserved names. The adapter no longer maintains DeepSeek-specific payload rewrites or a provider-protocol matrix.
 

+ 1 - 1
.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md

@@ -30,7 +30,7 @@ Status: implemented
 
 `dsh-llm-pi-ai` 接受一个非空的提供方配置列表。列表内的提供方名称必须唯一,并且存在于 pi-ai 的 `getProviders()` 结果中。每项配置包含提供方名称,以及可选的 `apiKey`、`baseURL`、headers、推理级别和预算、缓存保留设置、传输方式、SDK 超时、Harness 流空闲超时,以及由提供方拥有的 `retryPolicy`。适配器强制将 pi-ai 的 `maxRetries` 设为零,使一次 `stream()` 调用只发起一次可见的提供方请求;`dsh-llm-retry` 则在 agent 失败步骤扩展点上执行解析后的策略。凭据不设全局值:显式密钥仅对所属配置生效;未提供密钥时,pi-ai 使用标准环境变量、OAuth token、AWS 凭据链、Google ADC 或其他提供方原生环境认证。显式空密钥属于无效配置,不会回退到环境认证。
 
-插件通过一次全有或全无调用,将所有已配置的提供方名称注册到同一个 `PiAiAdapter`。请求按 provider 选择对应配置,并在 `getModels(provider)` 中查找模型以取得目录描述符。未知提供方会在插件加载时失败;未知模型会在网络 I/O 前以 `UNKNOWN_MODEL` 失败。适配器不会修改目录对象。当配置提供 `baseURL` 时,适配器复制选中的描述符,仅覆盖 `baseUrl`,使私有端点保留 pi-ai 的 API、能力、兼容标志、上下文限制与推理映射。私有端点必须实现所选提供方的协议,模型 ID 也仍须存在于已安装的 pi-ai 目录中。
+插件通过一次原子调用,将已配置的提供方名称注册到同一个 `PiAiAdapter`。每个不可变请求快照组合有效 profile 与可服务模型的描述符。目录外模型需要显式指定或可推断的协议与端点。根据[设置目录恢复决策](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md),已存储的目录错误保持可见、可修复,写入仍会校验已修改提供方,请求则在网络 I/O 前拒绝所选错误模型。
 
 适配器调用 pi-ai 的 `streamSimple()`,因此每个目录模型会选择其注册的 API 实现;描述符为 `openai-responses` 时使用 OpenAI Responses,而非 Chat Completions。Harness 的 temperature、最大 token 数、signal、session ID,以及提供方配置中的通用流选项均直接传递。配置 headers 与 Harness 强制归因 headers 合并;发生保留名称冲突时,以 Harness 归因为准。适配器不再维护 DeepSeek 专用 payload 重写或提供方协议矩阵。
 

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md
+2026-09-07-pi-ai-settings-catalog-recovery.md: 1af5a3cb5bd0a406103490cd1b87951f1956e6cd
+2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 3cab1f939b7d1589b17ddf82283dc1beae6509fc

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md

@@ -0,0 +1,39 @@
+# Agent Note: Repairable pi-ai settings after catalog changes
+
+Status: implemented
+
+English | [中文](2026-09-07-pi-ai-settings-catalog-recovery.zh.md)
+
+## Problem
+
+An installed pi-ai catalog can change the validity of unchanged user settings. OpenRouter models outside the catalog can inherit a protocol while all shipped models agree; adding a second protocol removes that inference. Removing a catalog model also invalidates an override keyed by its former id. Rejecting the entire settings namespace at registration makes unrelated providers disappear and removes the controls needed to repair the configuration.
+
+## Decision
+
+Settings separates shared `validate` checks from `validateWrite(next, previous)`. The latter runs inside the namespace's write queue, after resolution and before persistence, for update, replacement, and path mutation. Both values include the composition base. Registration and external reload do not invoke write-only checks; existing consumers retain their shared-validation behavior.
+
+The pi-ai consumer retains catalog diagnostics when reading stored profiles, while schema and self-contained profile constraints still reject loading. Writes strictly resolve each new or changed provider, comparing effective provider values against the committed snapshot. Unchanged failed providers do not block another provider's edit, and deletion remains possible. Editing a provider-wide setting validates all models it affects.
+
+Profile resolution keeps valid models beside per-model errors. A missing override retains its diagnostic without disabling the remaining catalog. A route-level catalog failure retains its provider and editable settings but supplies no callable models. The adapter checks the selected model's recorded failure before credentials or network I/O and reports `INVALID_CONFIG`. No protocol is guessed and no user configuration is rewritten during loading. Immutable snapshots still keep an in-flight request on its captured configuration.
+
+`LlmProviderInfo.configurationError` carries the first diagnostic for the provider row. Registration facts include that diagnostic so configuration repair refreshes the browser. Failed model ids remain in settings, while the model selector receives serviceable entries. Models settings displays the diagnostic and retains edit/delete controls. Both add actions require their owning settings namespace; the ordinary add menu filters out unavailable namespaces.
+
+This extends the [provider-routed adapter decision](../architecture/2026-07-14-provider-routed-llm-adapters.md): provider ownership and request snapshots remain unchanged, while catalog validity does not determine whether settings can be managed. That note remains active for routing, ownership, and replay rationale.
+
+## Alternatives considered
+
+**Reject catalog errors at registration.** This prevents users from repairing an otherwise parseable configuration and lets an unused stale model disable unrelated providers.
+
+**Relax save validation too.** A newly entered model with no inferable protocol can be rejected immediately with the offending provider and model named. Accepting it creates an avoidable request-time failure.
+
+**Strictly revalidate the entire namespace on every save.** An unrelated provider's old error would block adding a healthy provider or repairing providers independently.
+
+**Assign OpenRouter a fixed route protocol.** A route override replaces every model's protocol and can change working catalog entries that intentionally use another API.
+
+## Consequences
+
+Upgrade-dependent errors remain visible and repairable without weakening validation of new provider edits. Configuration errors remain distinct from remote model existence: a catalog-external id with an explicit protocol is accepted, and its endpoint decides whether that id exists. Scalar or document errors still fail early. The settings write hook adds no storage format or session event; provider metadata gains one optional diagnostic field.
+
+## Testing
+
+Settings tests cover write-only validation, persistence refusal, resolved previous values, and external reload. Adapter tests cover mixed valid/invalid models, deleted override referents, independent provider edits, route deletion, pre-network failure, and repair. The assembled Web expectation boots with stale OpenRouter settings, preserves zai and both add controls, rejects an invalid save without changing the file, and repairs the route by removing the stale model. Existing snapshot tests continue to own request freezing and replay behavior.

+ 39 - 0
.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md

@@ -0,0 +1,39 @@
+# Agent Note: pi-ai 目录变化后可修复的设置
+
+Status: implemented
+
+English | [中文](2026-09-07-pi-ai-settings-catalog-recovery.md)
+
+## Problem
+
+已安装的 pi-ai 目录可能改变未修改用户设置的有效性。OpenRouter 的目录外模型可以在所有内置模型协议一致时继承协议;加入第二种协议会使这种推断失效。删除目录模型也会使按其旧 ID 声明的覆盖失效。注册时拒绝整个 settings 命名空间,会让无关提供方消失,也移除了修复配置所需的控件。
+
+## Decision
+
+Settings 将共用的 `validate` 校验与 `validateWrite(next, previous)` 分开。后者在命名空间写入队列内部、解析之后、持久化之前执行,覆盖更新、整体替换和路径修改。两个值均包含组合基础层。注册和外部重载不执行仅写入校验;现有消费者保留其共用校验行为。
+
+pi-ai 消费者读取已存储 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。
+
+Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。
+
+`LlmProviderInfo.configurationError` 为提供方行携带首个诊断。注册事实包含该诊断,因此修复配置会刷新浏览器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。
+
+本决策扩展了[按提供方路由的适配器决策](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):提供方所有权与请求快照不变,目录有效性不决定设置是否可管理。旧记录仍保留为路由、所有权与回放设计的依据。
+
+## Alternatives considered
+
+**在注册时拒绝目录错误。** 这会阻止用户修复结构可解析的配置,并让未使用的过期模型禁用无关提供方。
+
+**同时放宽保存校验。** 对无法推断协议的新模型,可以立即拒绝并点名提供方与模型。接受它只会制造可避免的请求时错误。
+
+**每次保存都严格重校验整个命名空间。** 无关提供方的旧错误会阻止添加正常提供方,或逐个修复提供方。
+
+**为 OpenRouter 指定固定路由协议。** 路由覆盖会替换所有模型的协议,可能改变有意使用其他 API 的正常目录条目。
+
+## Consequences
+
+依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。Settings 写入钩子不增加存储格式或 session 事件;提供方元数据新增一个可选诊断字段。
+
+## Testing
+
+Settings 测试覆盖仅写入校验、持久化拒绝、已解析前值与外部重载。适配器测试覆盖有效与错误模型混合、覆盖目标被删除、独立提供方编辑、路由删除、联网前失败及修复。完整 Web 期望测试从过期 OpenRouter 设置启动,保留 zai 与两个添加控件,在不改变文件的前提下拒绝无效保存,并通过删除过期模型修复路由。已有快照测试继续负责请求冻结与回放行为。

+ 37 - 0
apps/web/tests/expected/models-settings-recovery/stored-error.expected.md

@@ -0,0 +1,37 @@
+- dialog "设置":
+  - navigation:
+    - text: 设置
+    - button "通用设置":
+      - img
+      - text: 通用设置
+    - button "模型":
+      - img
+      - text: 模型
+    - button "插件":
+      - img
+      - text: 插件
+    - button "Agent 预设":
+      - img
+      - text: Agent 预设
+  - button "打开配置文件"
+  - button "关闭":
+    - img
+    - text: 关闭
+  - heading "模型" [level=2]
+  - paragraph: 填入各提供方的 API 密钥即可使用其模型。
+  - list:
+    - listitem:
+      - text: openrouter
+      - button "编辑 openrouter": 编辑
+      - button "删除 openrouter": 删除
+      - alert: "llm-pi-ai: provider \"openrouter\" model \"111\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks"
+    - listitem:
+      - text: zai
+      - button "编辑 zai": 编辑
+      - button "删除 zai": 删除
+  - button "添加提供方":
+    - img
+    - text: 添加提供方
+  - button "添加自定义提供方":
+    - img
+    - text: 添加自定义提供方

+ 89 - 0
apps/web/tests/models-settings-recovery.e2e.ts

@@ -0,0 +1,89 @@
+/** Stored catalog drift remains repairable through the assembled Models settings page. */
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { chromium, type Browser, type Page } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import {
+  captureStableAria, compareOrRefreshGolden, launchWebScaffold,
+  watchConsole, webSnapshotMode, type WebScaffold,
+} from './scaffold.ts'
+import { saveFailureShot, ZH_BROWSER_LOCALE } from './support.ts'
+
+const EXPECTED = fileURLToPath(new URL('./expected/models-settings-recovery/stored-error.expected.md', import.meta.url))
+const FAILURE = 'llm-pi-ai: provider "openrouter" model "111" needs an api; '
+  + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks'
+
+describe('web e2e: repairs a stored provider after catalog drift', () => {
+  let home: string
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    home = await mkdtemp(join(tmpdir(), 'dsh-models-recovery-'))
+    await writeFile(join(home, 'settings.yaml'), [
+      'llm-pi-ai:', '  providers:', '    openrouter:', '      models:',
+      '        - id: "111"', '    zai: {}', '',
+    ].join('\n'))
+    scaffold = await launchWebScaffold({ harnessHome: home })
+    browser = await chromium.launch()
+    page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
+    await page.getByRole('button', { name: '设置', exact: true }).click()
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.getByRole('button', { name: '模型', exact: true }).click()
+    await dialog.getByText(FAILURE, { exact: true }).waitFor()
+  }, 120_000)
+
+  afterAll(async () => {
+    try {
+      await browser?.close()
+    } finally {
+      try {
+        await scaffold?.close()
+      } finally {
+        if (home !== undefined) await rm(home, { recursive: true, force: true })
+      }
+    }
+  })
+
+  it('shows the failed provider beside healthy providers and keeps both add actions usable', async () => {
+    onTestFailed(() => saveFailureShot(page, 'models-settings-recovery'))
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    expect(await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).count()).toBe(1)
+    expect(await dialog.getByRole('button', { name: '编辑 zai', exact: true }).count()).toBe(1)
+    expect(await dialog.getByRole('button', { name: '添加提供方', exact: true }).isEnabled()).toBe(true)
+    expect(await dialog.getByRole('button', { name: '添加自定义提供方', exact: true }).isEnabled()).toBe(true)
+    await compareOrRefreshGolden(EXPECTED, await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode())
+
+    await dialog.getByRole('button', { name: '添加提供方', exact: true }).click()
+    await dialog.getByLabel('提供方', { exact: true }).selectOption('minimax-cn')
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor()
+    expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toContain('minimax-cn: {}')
+    expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(1)
+  })
+
+  it('rejects an invalid edit without persisting and accepts removal of the obsolete model', async () => {
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    await dialog.getByRole('button', { name: '编辑 openrouter', exact: true }).click()
+    await dialog.getByText('自定义设置', { exact: true }).click()
+    await dialog.getByLabel('API 地址', { exact: true }).fill('https://gateway.example/v1')
+    const before = await readFile(join(home, 'settings.yaml'), 'utf8')
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    await expect.poll(() => dialog.getByText(FAILURE, { exact: true }).count()).toBe(2)
+    expect(await readFile(join(home, 'settings.yaml'), 'utf8')).toBe(before)
+    await dialog.getByRole('button', { name: '删除模型 1', exact: true }).click()
+    await dialog.getByRole('button', { name: '保存', exact: true }).click()
+    await dialog.getByText('已保存 openrouter。', { exact: true }).waitFor()
+    expect(await dialog.getByText(FAILURE, { exact: true }).count()).toBe(0)
+    const repaired = await readFile(join(home, 'settings.yaml'), 'utf8')
+    expect(repaired).not.toContain('111')
+    expect(repaired).toContain('baseURL: https://gateway.example/v1')
+    expect(tripwire.pageErrors).toEqual([])
+  })
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -42,6 +42,7 @@
     "tests/plugin-config.e2e.ts",
     "tests/settings-chrome.e2e.ts",
     "tests/models-settings.e2e.ts",
+    "tests/models-settings-recovery.e2e.ts",
     "tests/default-model.e2e.ts",
     "tests/github-ready-review.e2e.ts",
     "tests/streaming-fence-highlight.e2e.ts",

+ 2 - 2
docs/config-catalog.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 docs/config-catalog.md
-config-catalog.md: 90ec60c30c5ef2ef5fcf75e18ab3d2b51f9cac60
-config-catalog.zh.md: e5c3134561393afe3267808ee04a4cf46399a0f9
+config-catalog.md: 267e9866b12af7b4af71bda87a7d4603009aa3b4
+config-catalog.zh.md: d4676ae6adde5a735e9323dc0282f9c9441821fd

+ 1 - 1
docs/config-catalog.md

@@ -1335,7 +1335,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable<OpenAICompletionsCompat['
 
 Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
 
-Source: [`packages/llm/llm-pi-ai/src/config.ts:217`](../packages/llm/llm-pi-ai/src/config.ts)
+Source: [`packages/llm/llm-pi-ai/src/config.ts:221`](../packages/llm/llm-pi-ai/src/config.ts)
 
 <a id="deepseek-aidsh-llm-replay"></a>
 

+ 1 - 1
docs/config-catalog.zh.md

@@ -1337,7 +1337,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable<OpenAICompletionsCompat['
 
 依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`)
 
-来源:[`packages/llm/llm-pi-ai/src/config.ts:217`](../packages/llm/llm-pi-ai/src/config.ts)
+来源:[`packages/llm/llm-pi-ai/src/config.ts:221`](../packages/llm/llm-pi-ai/src/config.ts)
 
 <a id="deepseek-aidsh-llm-replay"></a>
 

+ 2 - 2
docs/subsystems/llm-streaming.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 docs/subsystems/llm-streaming.md
-llm-streaming.md: 97062fb326a2718daf33b19b2f7f00175a2ec1fa
-llm-streaming.zh.md: 9f4dc7d32bee62f55e971afb44905141cabe4e80
+llm-streaming.md: 8544517517cb7c1da4ee4afaaa881eebab8063c4
+llm-streaming.zh.md: 0036939d0a8703d38bca77918cba62861e589bee

+ 2 - 0
docs/subsystems/llm-streaming.md

@@ -459,6 +459,8 @@ interface LlmProviderInfo {
   id: string
   /** Human-readable provider name for selectors and diagnostics. */
   name: string
+  /** Configuration diagnostic; the route stays registered for inspection and repair. */
+  configurationError?: string
 }
 ```
 

+ 2 - 0
docs/subsystems/llm-streaming.zh.md

@@ -465,6 +465,8 @@ interface LlmProviderInfo {
   id: string
   /** Human-readable provider name for selectors and diagnostics. */
   name: string
+  /** Configuration diagnostic; the route stays registered for inspection and repair. */
+  configurationError?: string
 }
 ```
 

+ 2 - 2
docs/subsystems/settings.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 docs/subsystems/settings.md
-settings.md: d8e3cbc46eb697315d4938b696e921a0c2e11828
-settings.zh.md: 772fd7832ff80cab9444c16c9a1dc7ae1875d51e
+settings.md: 7d34bfa34471bc01475e147c5b3bbde74d0284ec
+settings.zh.md: a3562187545151bed4a4b582a94f6a92d0bc95e1

+ 8 - 0
docs/subsystems/settings.md

@@ -46,6 +46,14 @@ interface SettingsRegisterOptions<T> {
    * @param value - the resolved section, schema-valid by construction.
    */
   validate?: (value: T) => void
+  /**
+   * Check an in-process write after resolution and before persistence. Stored
+   * values do not run this check on registration or external reload, allowing
+   * the owner to expose repairable configuration after dependencies change.
+   * @param value - next resolved section.
+   * @param previous - current resolved section, including the composition base.
+   */
+  validateWrite?: (value: T, previous: T) => void
 }
 ```
 

+ 8 - 0
docs/subsystems/settings.zh.md

@@ -46,6 +46,14 @@ interface SettingsRegisterOptions<T> {
    * @param value - the resolved section, schema-valid by construction.
    */
   validate?: (value: T) => void
+  /**
+   * Check an in-process write after resolution and before persistence. Stored
+   * values do not run this check on registration or external reload, allowing
+   * the owner to expose repairable configuration after dependencies change.
+   * @param value - next resolved section.
+   * @param previous - current resolved section, including the composition base.
+   */
+  validateWrite?: (value: T, previous: T) => void
 }
 ```
 

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

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-settings-models/README.md
-README.md: 64a295e9ebacdd8cbb2b5a0a10b14c1c1dfb24ee
-README.zh.md: a75b6f2300eb59e2961b5ca85799b28b32d0856a
+README.md: 8c852023f6c7c90a4aad67beef8c4a3081e5968e
+README.zh.md: 6b5fd016dbc17bced816a16192c80640f56b9ab2

+ 2 - 0
packages/client/ui-settings-models/README.md

@@ -27,6 +27,8 @@ English | [中文](README.zh.md)
 
 Open the Models page from the Settings navigation to see every configured provider as a row. A whole-section provider whose key is not configured anywhere renders as its open setup card instead, but only in the first-run posture and only until the user closes that card. Each card kind owns its own open state, so closing one never discards a draft in another.
 
+A provider with a stored catalog error remains visible with its diagnostic and edit/delete actions. Add actions are offered only for registered settings namespaces, so an unavailable namespace cannot leave a button that opens no editor. A rejected save leaves the editor open and displays the Host diagnostic.
+
 ### API keys
 
 The primary field on an editor card is a single **API key** input — the page never asks for an environment-variable name. A typed key stores write-only 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`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and preserves provider-native authentication (for example the Bedrock credential chain or Vertex ADC). A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing. A successful Apply emits a local accessible status message without echoing secret material.

+ 2 - 0
packages/client/ui-settings-models/README.zh.md

@@ -27,6 +27,8 @@ kind: "package-reference"
 
 从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。
 
+存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。
+
 ### API 密钥
 
 编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。

+ 42 - 32
packages/client/ui-settings-models/src/client/ModelsSection.tsx

@@ -290,7 +290,8 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
   // step: whether the user already has a provider to talk to.
   const anyUsable = state.rows.some(providerUsable)
   const configured = state.rows.filter(row => row.configured)
-  const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
+  const configurable = state.rows.filter(row => state.namespaces.has(row.entry.settingsNs))
+  const addable = configurable.filter(row => !row.configured)
   const addTarget = adding ? editing : undefined
   const addNamespace = addTarget === undefined ? undefined : state.namespaces.get(addTarget.settingsNs)
   // The draft's directory row, for the card extension seat. A refresh can drop
@@ -322,11 +323,15 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
           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
+          const configurationError = row.entry.configurationError === undefined
+            ? null
+            : <p role="alert" className={styles['error']}>{row.entry.configurationError}</p>
           if (needsSetup(row, anyUsable) && !dismissedSetup.has(row.entry.provider)) {
             // First-run posture: the provider exists but has no key — the
             // setup card IS its presence on the page, until the user closes it.
             return (
               <li key={row.entry.provider} className={styles['setupCard']}>
+                {configurationError}
                 {renderProviderEditor({
                   target,
                   namespace,
@@ -416,6 +421,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
                     : null}
                 </span>
               </div>
+              {configurationError}
               {renderSlot(
                 'settings.models.provider-card',
                 { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) },
@@ -504,37 +510,41 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS
               // and equal-width so they read as siblings and line up with the
               // rows above, rather than two pills of different lengths.
               <div className={styles['addActions']}>
-                <button
-                  type="button"
-                  className={styles['addButton']}
-                  disabled={addable.length === 0 || !state.writable}
-                  onClick={() => {
-                    const first = addable[0]
-                    /* v8 ignore next -- the button is disabled while nothing is addable */
-                    if (first === undefined) return
-                    setSavedTarget(undefined)
-                    setDeclaring(false)
-                    setAdding(true)
-                    setEditing(targetOf(first))
-                  }}
-                >
-                  <IconPlusOutline16 size={14} />
-                  {t('add')}
-                </button>
-                <button
-                  type="button"
-                  className={styles['addButton']}
-                  disabled={protocols.length === 0 || !state.writable}
-                  onClick={() => {
-                    setSavedTarget(undefined)
-                    setAdding(false)
-                    setEditing(undefined)
-                    setDeclaring(true)
-                  }}
-                >
-                  <IconPlusOutline16 size={14} />
-                  {t('customAdd')}
-                </button>
+                {configurable.length > 0 && (
+                  <button
+                    type="button"
+                    className={styles['addButton']}
+                    disabled={addable.length === 0 || !state.writable}
+                    onClick={() => {
+                      const first = addable[0]
+                      /* v8 ignore next -- the button is disabled while nothing is addable */
+                      if (first === undefined) return
+                      setSavedTarget(undefined)
+                      setDeclaring(false)
+                      setAdding(true)
+                      setEditing(targetOf(first))
+                    }}
+                  >
+                    <IconPlusOutline16 size={14} />
+                    {t('add')}
+                  </button>
+                )}
+                {state.namespaces.has('llm-pi-ai') && (
+                  <button
+                    type="button"
+                    className={styles['addButton']}
+                    disabled={protocols.length === 0 || !state.writable}
+                    onClick={() => {
+                      setSavedTarget(undefined)
+                      setAdding(false)
+                      setEditing(undefined)
+                      setDeclaring(true)
+                    }}
+                  >
+                    <IconPlusOutline16 size={14} />
+                    {t('customAdd')}
+                  </button>
+                )}
               </div>
             )}
       </div>

+ 15 - 8
packages/client/ui-settings-models/src/client/store.ts

@@ -30,6 +30,7 @@ export interface ProviderDirectoryEntry {
   readonly settingsPath: readonly string[]
   readonly active: boolean
   readonly declared?: boolean
+  readonly configurationError?: string
 }
 
 /**
@@ -43,15 +44,20 @@ export function joinProviderDirectory(
   directory: readonly LlmConfigurableProvider[],
 ): ProviderDirectoryEntry[] {
   const active = new Set(registered.map(provider => provider.id))
+  const errors = new Map(registered.map(provider => [provider.id, provider.configurationError]))
   const declared = new Set(directory.map(entry => entry.provider))
-  const rows: ProviderDirectoryEntry[] = directory.map(entry => ({
-    provider: entry.provider,
-    displayName: entry.displayName,
-    settingsNs: entry.settingsNs,
-    settingsPath: [...entry.settingsPath],
-    active: active.has(entry.provider),
-    ...entry.declared === undefined ? {} : { declared: entry.declared },
-  }))
+  const rows: ProviderDirectoryEntry[] = directory.map((entry) => {
+    const configurationError = errors.get(entry.provider)
+    return {
+      provider: entry.provider,
+      displayName: entry.displayName,
+      settingsNs: entry.settingsNs,
+      settingsPath: [...entry.settingsPath],
+      active: active.has(entry.provider),
+      ...entry.declared === undefined ? {} : { declared: entry.declared },
+      ...configurationError === undefined ? {} : { configurationError },
+    }
+  })
   for (const provider of registered) {
     if (declared.has(provider.id)) continue
     rows.push({
@@ -60,6 +66,7 @@ export function joinProviderDirectory(
       settingsNs: '',
       settingsPath: [],
       active: true,
+      ...provider.configurationError === undefined ? {} : { configurationError: provider.configurationError },
     })
   }
   return rows

+ 35 - 0
packages/client/ui-settings-models/tests/components.client.spec.tsx

@@ -307,6 +307,41 @@ async function mountDeepSeekCard(overrides: Parameters<typeof scriptedFace>[0] =
 }
 
 describe('ModelsSection', () => {
+  it('hides both add actions when their settings namespaces are absent', async () => {
+    const scripted = scriptedFace()
+    scripted.face.settings.describe.mockResolvedValue(remoteOk({ writable: true, hasDocument: false, namespaces: [] }))
+    await mountFace(scripted)
+    expect(screen.queryByRole('button', { name: en.add })).toBeNull()
+    expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull()
+  })
+
+  it('offers only providers whose settings namespace can open an editor', async () => {
+    const scripted = scriptedFace()
+    scripted.face.settings.describe.mockResolvedValue(remoteOk({
+      writable: true, hasDocument: false,
+      namespaces: wireNamespaces().filter(view => view.ns !== 'llm-pi-ai'),
+    }))
+    await mountFace(scripted)
+    expect(screen.queryByRole('button', { name: en.customAdd })).toBeNull()
+    fireEvent.click(screen.getByRole('button', { name: en.add }))
+    expect(screen.queryByRole('option', { name: 'anthropic' })).toBeNull()
+    expect(screen.getByRole('option', { name: 'plain' })).toBeTruthy()
+  })
+
+  it('shows a catalog diagnostic while keeping the provider editable', async () => {
+    const scripted = scriptedFace()
+    const failure = 'llm-pi-ai: provider "openai" model "111" needs an api'
+    scripted.face.llm.listProviders.mockResolvedValue(remoteOk([
+      { id: 'openai', name: 'openai', configurationError: failure },
+    ]))
+    await mountFace(scripted)
+    expect(screen.getByRole('alert').textContent).toBe(failure)
+    fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.editProvider) }))
+    expect(await screen.findByLabelText(en.keyInput)).toBeTruthy()
+    expect(screen.getByRole('button', { name: en.add })).toBeTruthy()
+    expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
+  })
+
   it('renders nothing before the slot injects its dependencies', () => {
     const uninjected = {} as ModelsSectionProps
     render(<ModelsSection {...uninjected} />)

+ 9 - 1
packages/client/ui-settings-models/tests/store.client.spec.ts

@@ -4,7 +4,15 @@ import type { RpcResponse } from '@deepseek-ai/dsh-api-remotes/client'
 import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
 import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts'
 import { settingsSchema } from './settings-schema.client.ts'
-import { ModelsSettingsStore } from '../src/client/store.ts'
+import { joinProviderDirectory, ModelsSettingsStore } from '../src/client/store.ts'
+
+it('retains diagnostics for a live provider without a configurable directory entry', () => {
+  expect(joinProviderDirectory([{ id: 'orphan', name: 'Orphan', configurationError: 'catalog unavailable' }], []))
+    .toEqual([{
+      provider: 'orphan', displayName: 'Orphan', settingsNs: '', settingsPath: [], active: true,
+      configurationError: 'catalog unavailable',
+    }])
+})
 
 let nextRpc = 0
 function ok<T>(value: T): RpcResponse<T> {

+ 3 - 3
packages/extensions/tool-cordis/src/api-catalog.ts

@@ -4452,7 +4452,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'LlmProviderInfo',
-    declaration: 'export interface LlmProviderInfo {\n    id: string;\n    name: string;\n}',
+    declaration: 'export interface LlmProviderInfo {\n    id: string;\n    name: string;\n    configurationError?: string;\n}',
   },
   {
     name: 'LlmReasoningEffortInfo',
@@ -5452,7 +5452,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SettingsRegisterOptions',
-    declaration: 'export interface SettingsRegisterOptions<T> {\n    base?: Partial<T>;\n    applies?: SettingsApplies;\n    validate?: (value: T) => void;\n}',
+    declaration: 'export interface SettingsRegisterOptions<T> {\n    base?: Partial<T>;\n    applies?: SettingsApplies;\n    validate?: (value: T) => void;\n    validateWrite?: (value: T, previous: T) => void;\n}',
   },
   {
     name: 'SettingsSecretView',
@@ -5460,7 +5460,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'SettingsSectionHooks',
-    declaration: 'export interface SettingsSectionHooks<T> {\n    setSource(current: () => T): void;\n    onChange(): void;\n    validate?: (value: T) => void;\n}',
+    declaration: 'export interface SettingsSectionHooks<T> {\n    setSource(current: () => T): void;\n    onChange(): void;\n    validate?: (value: T) => void;\n    validateWrite?: (value: T, previous: T) => void;\n}',
   },
   {
     name: 'SettingsUpdateSource',

+ 2 - 2
packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md
-README.md: e9a59a830e694341174969670f59787a6fd34854
-README.zh.md: 67c14746d8bb7fa36cd3b45b45d79b0e81d9050e
+README.md: e9013b634e8a8b392f88e5e8986ace4a8fefac9b
+README.zh.md: 9c91e937ab31793085fa3627b88460817917e58a

+ 2 - 0
packages/llm/llm-pi-ai/README.md

@@ -114,6 +114,8 @@ The plugin answers "which models can this provider serve?" for a route a configu
 
 A route pi-ai does not ship needs `api`, `baseURL`, and a non-empty `models` list; an unserviceable profile is refused where it is written, naming the route and model. Failures carry stable codes: a credential that cannot be used fails with `INVALID_CREDENTIAL` naming the route and reference, a route whose `apiKeyEnv` reference resolves to nothing fails with `MISSING_CREDENTIAL`, an unconfigured model fails with `UNKNOWN_MODEL`, and terminal provider failures distinguish `QUOTA` from transient `RATE_LIMIT`. `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming UI cannot guarantee it across providers.
 
+Settings writes strictly validate each new or changed provider after merging its composition and user layers. Stored catalog failures retain the namespace and provider rows, with the first diagnostic in `LlmProviderInfo.configurationError`; unchanged failed providers do not block edits elsewhere. Serviceable models remain selectable, while unresolved models remain in the editable configuration and fail with `INVALID_CONFIG` before network I/O if requested directly. Repairing or deleting the offending configuration clears its diagnostic. Schema and self-contained profile errors still reject loading.
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 2 - 0
packages/llm/llm-pi-ai/README.zh.md

@@ -114,6 +114,8 @@ profile 通过可选 settings seam 每次操作重新读取:base 与用户的
 
 pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无法服务的 profile 会在写入处被拒绝,并点名路由与模型。失败携带稳定 code:无法使用的凭据以 `INVALID_CREDENTIAL` 失败并点名路由与引用,`apiKeyEnv` 引用解析为空的路由以 `MISSING_CREDENTIAL` 失败,未配置模型以 `UNKNOWN_MODEL` 失败,终止性提供方失败则区分 `QUOTA` 与暂时性 `RATE_LIMIT`。`GenerateOptions.stop` 以 `UNSUPPORTED_OPTION` 被拒绝,因为 pi-ai 的通用流式 UI 无法跨提供方保证它。
 
+Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmProviderInfo.configurationError` 返回首个诊断;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。
+
 -----
 
 <a id="understand-the-implementation"></a>

+ 13 - 3
packages/llm/llm-pi-ai/src/adapter.ts

@@ -233,7 +233,9 @@ export class PiAiAdapter extends LlmAdapter {
     const profiles = this.config.profiles()
     if (this.snapshot?.profiles === profiles) return this.snapshot
     const models: MutableModels = createModels(this.config.auth)
-    for (const profile of profiles.values()) models.setProvider(profile.piProvider)
+    for (const profile of profiles.values()) {
+      if (profile.piProvider !== undefined) models.setProvider(profile.piProvider)
+    }
     this.snapshot = { profiles, models }
     return this.snapshot
   }
@@ -249,7 +251,10 @@ export class PiAiAdapter extends LlmAdapter {
 
   /** The configured descriptor for one exact route/model pair within one snapshot. */
   private modelOf(snapshot: PiAiSnapshot, provider: string, model: string): Model<Api> {
-    this.profileOf(snapshot, provider)
+    const profile = this.profileOf(snapshot, provider)
+    const failure = profile.modelErrors.get(model)
+      ?? (profile.piProvider === undefined ? profile.catalogError : undefined)
+    if (failure !== undefined) throw new LlmError(failure, 'INVALID_CONFIG')
     const resolved = snapshot.models.getModel(provider, model)
     if (resolved === undefined) {
       throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, 'UNKNOWN_MODEL')
@@ -261,7 +266,12 @@ export class PiAiAdapter extends LlmAdapter {
     // The configured name, not the route key: `displayName` exists so a
     // deployment can label a route, and a label only the configuration surface
     // reads would leave every selector showing the raw key.
-    return { id: provider, name: this.current().profiles.get(provider)?.displayName ?? provider }
+    const profile = this.current().profiles.get(provider)
+    return {
+      id: provider,
+      name: profile?.displayName ?? provider,
+      ...profile?.catalogError === undefined ? {} : { configurationError: profile.catalogError },
+    }
   }
 
   override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {

+ 39 - 15
packages/llm/llm-pi-ai/src/catalog.ts

@@ -5,9 +5,9 @@
  * stays configuration-free while a route pi-ai has never heard of is fully
  * describable from `settings.yaml`.
  *
- * Every pi-ai `Model` field the harness cannot default is required here rather
- * than at request time: an unserviceable route fails while its configuration is
- * being resolved, which is the earliest point that can name the offending key.
+ * Strict resolution rejects unserviceable models before settings writes.
+ * Deferred resolution retains their diagnostics so stored catalog drift does
+ * not prevent inspection, repair, or requests to independently valid models.
  *
  * @module dsh-llm-pi-ai/catalog
  */
@@ -640,9 +640,12 @@ export interface RouteCatalogRequest {
   defaultInput: Model<Api>['input']
 }
 
+/** An expected configuration failure that stored-catalog reads may retain for repair. */
+export class PiAiCatalogError extends Error {}
+
 /** Report a route the deployment cannot serve, naming the settings key at fault. */
 function invalid(provider: string, detail: string): never {
-  throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`)
+  throw new PiAiCatalogError(`llm-pi-ai: provider "${provider}" ${detail}`)
 }
 
 /**
@@ -798,6 +801,8 @@ function resolveModelCompat(
 export interface RouteCatalog {
   /** The materialized models in configuration order. */
   models: readonly Model<Api>[]
+  /** Models that cannot be resolved, retained as diagnostics during stored-config reads. */
+  modelErrors: ReadonlyMap<string, string>
   /**
    * Per-request output caps this profile explicitly configured, by model id.
    *
@@ -817,9 +822,13 @@ export interface RouteCatalog {
  * installed catalog unchanged, which is what keeps an existing
  * `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched.
  * @param request - the route-level catalog facts.
+ * @param validation - strict writes reject every error; deferred reads retain model diagnostics.
  * @returns the materialized models and the explicitly configured request caps.
  */
-export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
+export function resolveRouteModels(
+  request: RouteCatalogRequest,
+  validation: 'strict' | 'deferred' = 'strict',
+): RouteCatalog {
   const { provider } = request
   const defaults = catalogModels(provider)
   const providerBaseUrl = catalogProvider(provider)?.baseUrl
@@ -828,8 +837,9 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
   // serve no request anyway, so both mean "serve the installed catalog".
   const configured = request.models ?? []
   const overrides = request.modelOverrides ?? {}
-  // Every miss is refused, never skipped: an override that lands nowhere is a
-  // typo someone would otherwise hunt for in a silently unchanged model.
+  const modelErrors = new Map<string, string>()
+  // Writes reject missing referents. Stored overrides retain a diagnostic
+  // after catalog removal rather than silently disappearing.
   for (const [id, override] of Object.entries(overrides)) {
     if (id.length === 0) invalid(provider, 'has a modelOverrides entry with an empty model id')
     if (defaults.size === 0) {
@@ -841,7 +851,9 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
         + ' catalog, so declare the fields on its entries')
     }
     if (!defaults.has(id)) {
-      invalid(provider, `modelOverrides names "${id}", which the installed catalog does not describe`)
+      const message = `modelOverrides names "${id}", which the installed catalog does not describe`
+      if (validation === 'strict') invalid(provider, message)
+      modelErrors.set(id, `llm-pi-ai: provider "${provider}" ${message}`)
     }
     // The id lives in the dict key; a value carrying its own would quietly
     // rename the model it meant to customize. The static shape already omits
@@ -865,12 +877,10 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
   // wherever it is written, so it cannot look applied on a route whose models
   // never reach the protocol that would have taken it.
   assertOfferedCompatFields(provider, 'route', request.compat)
-  for (const entry of entries) {
-    assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat)
-  }
   const seen = new Set<string>()
   const configuredMaxTokens = new Map<string, number>()
-  const models = entries.map((entry) => {
+  const resolveEntry = (entry: PiAiModelProfile): Model<Api> => {
+    assertOfferedCompatFields(provider, `model "${entry.id}"`, entry.compat)
     if (entry.id.length === 0) invalid(provider, 'has a model with an empty id')
     if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`)
     seen.add(entry.id)
@@ -918,16 +928,30 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog {
       ...resolveModelReasoning(provider, entry, base),
       ...resolveModelCompat(provider, entry, request.compat, base, api),
     }
-  })
+  }
+  const models: Model<Api>[] = []
+  for (const entry of entries) {
+    let model: Model<Api>
+    try {
+      model = resolveEntry(entry)
+    } catch (error) {
+      if (validation === 'strict' || !(error instanceof PiAiCatalogError)) throw error
+      modelErrors.set(entry.id, error.message)
+      continue
+    }
+    models.push(model)
+  }
+  // A later duplicate invalidates the id, including an earlier resolved entry.
+  const serviceableModels = models.filter(model => !modelErrors.has(model.id))
   // Per field, not per block: a route may default a switch its completions
   // models take beside one only its anthropic models do, and neither should
   // fail for the other's sake. What is refused is a route default no model on
   // the route could ever read, which is a route that will not behave as written.
   for (const [field] of configuredCompatEntries(request.compat)) {
     const takers = compatProtocols(field)
-    if (models.some(model => takers.includes(model.api))) continue
+    if (serviceableModels.some(model => takers.includes(model.api))) continue
     invalid(provider, `sets compat "${field}", but no model on the route speaks a protocol that takes it;`
       + ` it exists on ${takers.join(', ')}`)
   }
-  return { models, configuredMaxTokens }
+  return { models: serviceableModels, configuredMaxTokens, modelErrors }
 }

+ 66 - 43
packages/llm/llm-pi-ai/src/config.ts

@@ -6,10 +6,9 @@
  * A route key is not required to name an installed pi-ai provider. When it does,
  * that provider's endpoint, protocol, display name, and model catalog are the
  * profile's defaults and the profile overrides them field by field; when it does
- * not, the profile is the whole provider declaration. Resolution therefore ends
- * in a built pi-ai `Provider` per route: everything a request needs is decided
- * once, while the configuration key that made a route unserviceable can still be
- * named in the failure.
+ * not, the profile is the whole provider declaration. Stored reads retain
+ * catalog diagnostics beside serviceable models; writes validate every changed
+ * provider before persistence. Self-contained profile constraints apply to both.
  *
  * @module dsh-llm-pi-ai/config
  */
@@ -21,11 +20,13 @@ import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
 import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
 import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
+import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
 import {
   CACHE_CONTROL_FORMATS,
   CHAT_TEMPLATE_VARS,
   MAX_TOKENS_FIELDS,
   MODALITIES,
+  PiAiCatalogError,
   resolveRouteModels,
   SUPPORTED_THINKING_FORMATS,
   THINKING_LEVELS,
@@ -37,6 +38,7 @@ import type {
   PiAiModelOverride,
   PiAiModelProfile,
   PiAiReasoningEfforts,
+  RouteCatalog,
 } from './catalog.ts'
 import { buildProvider, supportedProtocols } from './provider.ts'
 
@@ -199,12 +201,14 @@ export interface ResolvedPiAiProviderProfile
   /** Immutable retry policy captured with this provider route. */
   retryPolicy: ResolvedRetryPolicy
   /**
-   * The pi-ai provider this route registers, built from the resolved models.
-   * Construction happens here so an unserviceable protocol or an underspecified
-   * model fails with the rest of resolution, leaving the last good route set
-   * serving requests.
+   * The pi-ai provider containing this route's serviceable models. Absent when
+   * a stored route cannot be constructed; its configuration remains editable.
    */
-  piProvider: Provider
+  piProvider?: Provider
+  /** First stored-catalog diagnostic for settings surfaces; absent for a serviceable route. */
+  catalogError?: string
+  /** Per-model failures reported before attempting a request. */
+  modelErrors: ReadonlyMap<string, string>
   /**
    * Per-request output caps this profile explicitly configured, by model id.
    * The seam materializes one only into a request that names no cap of its
@@ -346,19 +350,25 @@ export const Config: z<Config> = z.object({
 })
 
 /**
- * Reject a section this adapter could not serve. Registered as the settings
- * namespace's validator, so an unserviceable profile is refused where it is
- * *written* — `settings.mutate` answers `settings-rejected` with the offending
- * route and model named — instead of being stored and then quietly disabling
- * every route in the namespace. It stays a validator rather than a schema
- * transform because the schema is also the shape a configuration surface
- * renders and the value an absent section resolves to; wrapping it would break
- * both.
+ * Reject new or changed provider profiles that cannot be served. Unchanged
+ * stored profiles may need repair after a catalog upgrade and do not block
+ * edits to another provider. Removed profiles require no catalog validation.
  * @param config - the resolved section to check.
+ * @param previous - current resolved section; omission checks every provider.
  * @throws Error naming the route and configuration entry that cannot be served.
  */
-export function assertServiceable(config: Config): void {
-  resolveProfiles(config.providers)
+export function assertServiceable(config: Config, previous?: Config): void {
+  const changed = Object.fromEntries(Object.entries(config.providers ?? {}).filter(([provider, profile]) =>
+    !deepEqualJson(profile, previous?.providers?.[provider])))
+  resolveProfiles(changed)
+}
+
+/**
+ * Check self-contained profile requirements without rejecting stored catalog drift.
+ * @param config - schema-resolved section read from settings.
+ */
+export function assertReadable(config: Config): void {
+  resolveProfiles(config.providers, 'deferred')
 }
 
 /** Reject removed pre-release profile fields and name their replacements. */
@@ -394,15 +404,16 @@ function assertValidHeaders(provider: string, headers: Readonly<Record<string, s
 }
 
 /**
- * Validate profiles and return a detached route-keyed map suitable for
- * per-request reads. This is the one explicit resolve step, so an omitted dict
- * resolves to the empty (dormant) route set here rather than through a hidden
- * fallback, and each route's models and pi-ai provider are materialized once.
+ * Resolve scalar defaults and materialize each route's serviceable models.
+ * Deferred catalog validation retains diagnostics without deleting configured
+ * routes. An omitted dict resolves to the empty, dormant route set.
  * @param providers - configured provider profiles keyed by route.
+ * @param validation - writes require a complete catalog; stored reads retain catalog diagnostics.
  * @returns validated profiles in configuration order.
  */
 export function resolveProfiles(
   providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
+  validation: 'strict' | 'deferred' = 'strict',
 ): Map<string, ResolvedPiAiProviderProfile> {
   if (Array.isArray(providers)) {
     throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles')
@@ -452,17 +463,34 @@ export function resolveProfiles(
     // always shown route keys, and a catalog route must not silently rename
     // itself on every configuration surface just because it gained a profile.
     const displayName = source.displayName ?? provider
-    const catalog = resolveRouteModels({
-      provider,
-      ...source.api === undefined ? {} : { api: source.api },
-      ...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
-      ...source.models === undefined ? {} : { models: source.models },
-      ...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides },
-      ...source.compat === undefined ? {} : { compat: source.compat },
-      defaultInput,
-      defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
-      defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
-    })
+    let catalog: RouteCatalog | undefined
+    let piProvider: Provider | undefined
+    let catalogError: string | undefined
+    try {
+      catalog = resolveRouteModels({
+        provider,
+        ...source.api === undefined ? {} : { api: source.api },
+        ...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
+        ...source.models === undefined ? {} : { models: source.models },
+        ...source.modelOverrides === undefined ? {} : { modelOverrides: source.modelOverrides },
+        ...source.compat === undefined ? {} : { compat: source.compat },
+        defaultInput,
+        defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW,
+        defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS,
+      }, validation)
+      piProvider = buildProvider({
+        provider,
+        displayName,
+        ...source.api === undefined ? {} : { api: source.api },
+        ...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
+        models: catalog.models,
+        namesCredential: source.apiKeyEnv !== undefined,
+      })
+      catalogError = catalog.modelErrors.values().next().value
+    } catch (error) {
+      if (validation === 'strict' || !(error instanceof PiAiCatalogError)) throw error
+      catalogError = error.message
+    }
     const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source
     resolved.set(provider, {
       ...rest,
@@ -476,15 +504,10 @@ export function resolveProfiles(
       retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
       ...rest.headers === undefined ? {} : { headers: { ...rest.headers } },
       ...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
-      configuredMaxTokens: catalog.configuredMaxTokens,
-      piProvider: buildProvider({
-        provider,
-        displayName,
-        ...source.api === undefined ? {} : { api: source.api },
-        ...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
-        models: catalog.models,
-        namesCredential: apiKeyEnv !== undefined,
-      }),
+      configuredMaxTokens: catalog?.configuredMaxTokens ?? new Map(),
+      modelErrors: catalog?.modelErrors ?? new Map(),
+      ...piProvider === undefined ? {} : { piProvider },
+      ...catalogError === undefined ? {} : { catalogError },
     })
   }
   return resolved

+ 10 - 11
packages/llm/llm-pi-ai/src/index.ts

@@ -65,7 +65,7 @@ import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
 import { PiAiAdapter } from './adapter.ts'
 import { authContextFrom, credentialStoreFrom } from './auth.ts'
 import { catalogProviderIds } from './catalog.ts'
-import { assertServiceable, Config, resolveProfiles } from './config.ts'
+import { assertReadable, assertServiceable, Config, resolveProfiles } from './config.ts'
 import type { ResolvedPiAiProviderProfile } from './config.ts'
 import { discoverModels } from './discovery.ts'
 import type { StoredModelDiscoveryProfile } from './discovery.ts'
@@ -106,6 +106,7 @@ function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderPro
       provider,
       displayName: profile.displayName,
       retryPolicy: profile.retryPolicy,
+      catalogError: profile.catalogError,
     }))
     .sort((left, right) => left.provider.localeCompare(right.provider))
 }
@@ -150,16 +151,14 @@ export function apply(ctx: Context, config: Config): void {
    * snapshot's identity — which is also what makes the adapter's own snapshot
    * stable across operations that observe no change.
    *
-   * No fallback for an unserviceable snapshot lives here: the section schema
-   * resolves the whole profile set, so a write that could not be served is
-   * refused where it is written, and the settings seam keeps a namespace's
-   * last good value for a stored section that fails. Anything reaching this
-   * point has already resolved once.
+   * Catalog diagnostics stay in the snapshot beside serviceable models, so
+   * stored configuration remains visible after an installed catalog changes.
+   * Scalar configuration errors still reject resolution.
    */
   const profiles = (): ReadonlyMap<string, ResolvedPiAiProviderProfile> => {
     const raw = current()
     if (raw === lastRaw && memoized !== undefined) return memoized
-    const next = resolveProfiles(raw.providers)
+    const next = resolveProfiles(raw.providers, 'deferred')
     lastRaw = raw
     memoized = next
     return next
@@ -295,10 +294,10 @@ export function apply(ctx: Context, config: Config): void {
 
   ctx.inject(['settings'], (settingsCtx) => {
     settingsCtx.settings.installSection(ctx, NS, Config, config, {
-      // Refuse an unserviceable section where it is written: without this a
-      // schema-valid profile the adapter cannot serve would be stored and then
-      // silently disable every route in this namespace.
-      validate: assertServiceable,
+      // Reading stored catalog drift keeps the repair UI; writes still refuse
+      // any changed provider whose catalog cannot be served.
+      validate: assertReadable,
+      validateWrite: assertServiceable,
       setSource: (source) => {
         current = source
       },

+ 2 - 2
packages/llm/llm-pi-ai/src/provider.ts

@@ -24,7 +24,7 @@ import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendi
 import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy'
 import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
 import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy'
-import { catalogProvider } from './catalog.ts'
+import { catalogProvider, PiAiCatalogError } from './catalog.ts'
 
 /**
  * Wire protocols a configured route may name, mapped to pi-ai's lazily loaded
@@ -176,7 +176,7 @@ export function buildProvider(spec: ProviderSpec): Provider {
   // replaces each catalog model's own. So the route has a single API.
   const factory = spec.api === undefined ? undefined : PROTOCOLS[spec.api]
   if (factory === undefined) {
-    throw new Error(
+    throw new PiAiCatalogError(
       `llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve;`
       + ` supported protocols are ${supportedProtocols().join(', ')}`,
     )

+ 25 - 13
packages/llm/llm-pi-ai/tests/catalog.spec.ts

@@ -171,7 +171,7 @@ describe('hand-declared providers', () => {
       },
     })
     const modelsOf = (route: string): readonly { id: string; contextWindow: number; maxTokens: number }[] =>
-      resolved.get(route)?.piProvider.getModels() ?? []
+      resolved.get(route)?.piProvider?.getModels() ?? []
 
     expect(modelsOf('acme-gateway')).toMatchObject([
       { id: 'bare', contextWindow: 262_144, maxTokens: 32_768 },
@@ -210,7 +210,7 @@ describe('hand-declared providers', () => {
       'anthropic': { defaultInput: ['text'] },
     })
     const inputOf = (route: string, id: string): readonly string[] | undefined =>
-      resolved.get(route)?.piProvider.getModels().find(model => model.id === id)?.input
+      resolved.get(route)?.piProvider?.getModels().find(model => model.id === id)?.input
 
     expect(inputOf('acme-gateway', 'bare')).toEqual(['text'])
     expect(inputOf('acme-gateway', 'seeing')).toEqual(['text', 'image'])
@@ -273,8 +273,8 @@ describe('hand-declared providers', () => {
         models: [{ id: 'bare', input: [] }],
       },
     })
-    expect(resolved.get('acme-gateway')?.piProvider.getModels()[0]?.input).toEqual(['text'])
-    expect(resolved.get('deepseek')?.piProvider.getModels()[0]?.input).toEqual(catalogModel.input)
+    expect(resolved.get('acme-gateway')?.piProvider?.getModels()[0]?.input).toEqual(['text'])
+    expect(resolved.get('deepseek')?.piProvider?.getModels()[0]?.input).toEqual(catalogModel.input)
 
     // Nothing sits below the route value, so its empty list states no answer
     // anything could take, and is refused where it is written.
@@ -302,6 +302,18 @@ describe('hand-declared providers', () => {
     })).toThrow(/more than once/)
   })
 
+  it('retains duplicate-id diagnostics without offering the ambiguous model after loading', () => {
+    const profile = resolveProfiles({
+      'acme-gateway': {
+        api: 'openai-completions',
+        baseURL: 'https://acme.test',
+        models: [{ id: 'dup' }, { id: 'valid' }, { id: 'dup' }],
+      },
+    }, 'deferred').get('acme-gateway')
+    expect(profile?.modelErrors.get('dup')).toContain('lists model "dup" more than once')
+    expect(profile?.piProvider?.getModels().map(model => model.id)).toEqual(['valid'])
+  })
+
   it('rejects a declaration that names no wire protocol or endpoint', () => {
     expect(() => resolveProfiles({
       'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
@@ -494,7 +506,7 @@ describe('catalog routes with per-model configuration', () => {
     const resolved = resolveProfiles({
       nvidia: { models: [{ id: headered.id, contextWindow: 4096 }] },
     })
-    const [model] = resolved.get('nvidia')?.piProvider.getModels() ?? []
+    const [model] = resolved.get('nvidia')?.piProvider?.getModels() ?? []
     expect(model?.headers).toEqual(headered.headers)
     expect(model?.contextWindow).toBe(4096)
   })
@@ -520,15 +532,15 @@ describe('catalog routes with per-model configuration', () => {
     // `opencode` ships no provider-level endpoint: the address lives on every
     // catalog model, so the route resolves without any configured baseURL.
     const resolved = resolveProfiles({ opencode: {} })
-    const models = resolved.get('opencode')?.piProvider.getModels() ?? []
+    const models = resolved.get('opencode')?.piProvider?.getModels() ?? []
     expect(models.length).toBeGreaterThan(0)
     expect(models.every(model => model.baseUrl.length > 0)).toBe(true)
-    expect(resolved.get('opencode')?.piProvider.baseUrl).toBeUndefined()
+    expect(resolved.get('opencode')?.piProvider?.baseUrl).toBeUndefined()
   })
 
   it('repoints a catalog route at another wire protocol without restating its endpoint', () => {
     const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
-    const models = resolved.get('openai')?.piProvider.getModels() ?? []
+    const models = resolved.get('openai')?.piProvider?.getModels() ?? []
     // The protocol changes for the whole route; each model keeps the catalog
     // endpoint it already had.
     expect(models.every(model => model.api === 'openai-completions')).toBe(true)
@@ -559,7 +571,7 @@ describe('catalog routes with per-model configuration', () => {
     // the wire format its models speak: naming an api must not cost a profile
     // its provider-native discovery.
     const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
-    expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key')
+    expect(resolved.get('openai')?.piProvider?.auth.apiKey?.name).toBe('OpenAI API key')
   })
 
   it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => {
@@ -582,7 +594,7 @@ describe('catalog routes with per-model configuration', () => {
     // and holds no OAuth store, so declaring the provider configured would
     // trade a truthful refusal for an endpoint's 401.
     const resolved = resolveProfiles({ 'openai-codex': {} })
-    expect(resolved.get('openai-codex')?.piProvider.auth.apiKey).toBeUndefined()
+    expect(resolved.get('openai-codex')?.piProvider?.auth.apiKey).toBeUndefined()
   })
 })
 
@@ -594,7 +606,7 @@ describe('per-model reasoning efforts', () => {
 
   /** The first materialized model of one route, or throw. */
   function modelOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route = 'acme-gateway'): Model<Api> {
-    const [model] = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
+    const [model] = resolveProfiles(providers).get(route)?.piProvider?.getModels() ?? []
     if (model === undefined) throw new Error(`route "${route}" resolved no models`)
     return model
   }
@@ -705,7 +717,7 @@ describe('modelOverrides', () => {
         },
       },
     })
-    const models = resolved.get('deepseek')?.piProvider.getModels() ?? []
+    const models = resolved.get('deepseek')?.piProvider?.getModels() ?? []
     const reshaped = models.find(model => model.id === target.id)
     if (reshaped === undefined) throw new Error('the overridden model vanished from the route')
 
@@ -758,7 +770,7 @@ describe('modelOverrides', () => {
 describe('compat switches', () => {
   /** The materialized models of one route, keyed by id. */
   function modelsOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route: string): Map<string, Model<Api>> {
-    const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
+    const models = resolveProfiles(providers).get(route)?.piProvider?.getModels() ?? []
     return new Map(models.map(model => [model.id, model]))
   }
 

+ 1 - 1
packages/llm/llm-pi-ai/tests/compat-upgrade.spec.ts

@@ -9,7 +9,7 @@ function configured(compat: Record<string, unknown>, api = 'openai-completions')
 }
 
 function resolved(compat: Record<string, unknown>, api = 'openai-completions') {
-  return resolveProfiles(configured(compat, api).providers).get('gateway')?.piProvider.getModels()[0]?.compat
+  return resolveProfiles(configured(compat, api).providers).get('gateway')?.piProvider?.getModels()[0]?.compat
 }
 
 describe('pi-ai gateway compatibility declarations', () => {

+ 10 - 1
packages/llm/llm-pi-ai/tests/config.spec.ts

@@ -1,5 +1,5 @@
 import { describe, expect, it } from 'vitest'
-import { assertServiceable, Config } from '../src/config.ts'
+import { assertServiceable, Config, resolveProfiles } from '../src/config.ts'
 
 /** Validate one hand-declared route, with the caller's fields layered onto it. */
 const routeWith = (profile: Record<string, unknown>): (() => unknown) =>
@@ -19,6 +19,15 @@ const configWith = (model: Record<string, unknown>): (() => unknown) =>
   routeWith({ models: [{ id: 'm', ...model }] })
 
 describe('reasoning schema boundary', () => {
+  it('accepts an empty provider section and propagates unexpected catalog failures', () => {
+    expect(() => { assertServiceable({}) }).not.toThrow()
+    const failure = new TypeError('model metadata lookup failed')
+    expect(() => resolveProfiles({ openrouter: { models: [{
+      id: '111',
+      get name(): string { throw failure },
+    }], api: 'openai-completions' } }, 'deferred')).toThrow(failure)
+  })
+
   it('rejects a level pi-ai does not know at the write that produced it', () => {
     expect(configWith({ reasoningEfforts: { ultra: 'x' } })).toThrow(/"off"/)
     expect(configWith({ reasoningEfforts: { high: 42 } })).toThrow()

+ 63 - 1
packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts

@@ -1,6 +1,6 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
-import { mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import LlmRuntime, { LlmAdapter } from '@deepseek-ai/dsh-llm'
@@ -9,6 +9,7 @@ import { LocalCredentialProvider } from '@deepseek-ai/dsh-credentials-local'
 import { FileSettingsProvider } from '@deepseek-ai/dsh-settings-file'
 import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
 import AuthorizationService from '@deepseek-ai/dsh-authorization'
+import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
 import { assemble } from './assemble.ts'
 import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
 
@@ -74,6 +75,67 @@ describe('login flows in a real composition', () => {
 })
 
 describe('request-level dynamic profiles', () => {
+  it('keeps stored catalog failures editable while isolating requests and validating changed providers', async () => {
+    vi.stubEnv('PI_DYNAMIC_KEY', '')
+    const dir = await home()
+    const server = await mockServer([{ events: textEvents }, { events: textEvents }])
+    const known = getBuiltinModels('openrouter').find(model => model.api === 'openai-completions')!
+    const path = join(dir, 'settings.yaml')
+    const stored = JSON.stringify({
+      [NS]: { providers: { openrouter: {
+        apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url,
+        models: [{ id: known.id }, { id: '111' }],
+      } } },
+    })
+    await writeFile(path, stored)
+    await writeFile(join(dir, '.credentials.yaml'), 'version: 1\nrefs:\n  PI_DYNAMIC_KEY: fake-key\n', { mode: 0o600 })
+    const ctx = await boot(dir, {})
+    const failure = 'llm-pi-ai: provider "openrouter" model "111" needs an api; '
+      + 'the installed catalog does not describe it, so set the route\'s api to the wire protocol its endpoint speaks'
+
+    expect(ctx.settings.describe().map(section => section.ns)).toContain(NS)
+    expect(ctx.llm.listProviders()).toContainEqual({ id: 'openrouter', name: 'openrouter', configurationError: failure })
+    expect(await readFile(path, 'utf8')).toBe(stored)
+    expect((await ctx.llm.listModels('openrouter')).map(model => model.id)).toEqual([known.id])
+    const bad = await assemble(ctx, { provider: 'openrouter', model: '111', messages: [] })
+    expect(bad.finish).toMatchObject({ kind: 'error', failure: { code: 'INVALID_CONFIG', message: failure } })
+    expect(server.requests).toHaveLength(0)
+    const good = await assemble(ctx, { provider: 'openrouter', model: known.id, messages: [] })
+    expect(good.message.content).toEqual([{ type: 'text', text: 'hello' }])
+
+    await ctx.settings.update(NS, { providers: { deepseek: { apiKeyEnv: 'PI_DYNAMIC_KEY', baseURL: server.url } } })
+    expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openrouter', 'deepseek'])
+    const beforeRejected = await readFile(path, 'utf8')
+    await expect(ctx.settings.update(NS, { providers: { openrouter: { displayName: 'Edited' } } })).rejects.toThrow(failure)
+    expect(await readFile(path, 'utf8')).toBe(beforeRejected)
+
+    await ctx.settings.mutate(NS, [{ op: 'set', path: ['providers', 'openrouter', 'api'], value: 'openai-completions' }])
+    expect(ctx.llm.listProviders()[0]).toEqual({ id: 'openrouter', name: 'openrouter' })
+    const repaired = await assemble(ctx, { provider: 'openrouter', model: '111', messages: [] })
+    expect(repaired.message.content).toEqual([{ type: 'text', text: 'hello' }])
+    expect(server.requests).toHaveLength(2)
+  })
+
+  it('allows removing an obsolete override and deleting a route whose catalog cannot be built', async () => {
+    const dir = await home()
+    await writeFile(join(dir, 'settings.yaml'), JSON.stringify({ [NS]: { providers: {
+      anthropic: { modelOverrides: { 'removed-model': { maxTokens: 4096 } } },
+      'retired-route': {},
+    } } }))
+    const ctx = await boot(dir, {})
+    expect(ctx.settings.describe().map(section => section.ns)).toContain(NS)
+    expect(ctx.llm.listProviders().map(provider => provider.configurationError)).toEqual([
+      expect.stringContaining('modelOverrides names "removed-model"'),
+      expect.stringContaining('resolves no models'),
+    ])
+    expect((await ctx.llm.listModels('anthropic')).length).toBeGreaterThan(0)
+    await expect(ctx.llm.resolveModelInfo('anthropic', 'removed-model')).rejects.toThrow('modelOverrides names "removed-model"')
+    await expect(ctx.llm.resolveModelInfo('retired-route', 'anything')).rejects.toThrow('resolves no models')
+    await ctx.settings.mutate(NS, [{ op: 'unset', path: ['providers', 'retired-route'] }])
+    await ctx.settings.mutate(NS, [{ op: 'unset', path: ['providers', 'anthropic', 'modelOverrides', 'removed-model'] }])
+    expect(ctx.llm.listProviders()).toEqual([{ id: 'anthropic', name: 'anthropic' }])
+  })
+
   it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => {
     vi.stubEnv('PI_DYNAMIC_KEY', '')
     const dir = await home()

+ 2 - 2
packages/llm/llm/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/llm/llm/README.md
-README.md: 0f84af8418f916a77589907656ed310ae2bb73f7
-README.zh.md: 2c306940af912176a87d80a2552808cc2b644554
+README.md: a0f79106765e17437cf6bfd2cbf91e2b0cef34a9
+README.zh.md: fe99f7680e07f1742869c12692668ae426083d60

+ 1 - 1
packages/llm/llm/README.md

@@ -54,7 +54,7 @@ for await (const chunk of ctx.llm.stream({
 }
 ```
 
-After a successful mount, `ctx.llm.listProviders()` reports the registered routes in registration order.
+After a successful mount, `ctx.llm.listProviders()` reports the registered routes in registration order. Adapters may include `configurationError` in provider metadata to keep a failed configuration visible for repair; this diagnostic does not unregister its route.
 
 ### What you can do
 

+ 1 - 1
packages/llm/llm/README.zh.md

@@ -54,7 +54,7 @@ for await (const chunk of ctx.llm.stream({
 }
 ```
 
-挂载成功后,`ctx.llm.listProviders()` 会按注册顺序报告已注册路由。
+挂载成功后,`ctx.llm.listProviders()` 会按注册顺序报告已注册路由。适配器可以在提供方元数据中携带 `configurationError`,保留错误配置的可见性以供修复;此诊断不会注销路由。
 
 ### 你可以做什么
 

+ 4 - 1
packages/llm/llm/src/index.ts

@@ -434,7 +434,10 @@ export class LlmRuntime extends TypertRemoteService {
         ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
       registrations.push({
         adapter,
-        provider: { id: info.id, name: info.name },
+        provider: {
+          id: info.id, name: info.name,
+          ...info.configurationError === undefined ? {} : { configurationError: info.configurationError },
+        },
         retryPolicy,
       })
     }

+ 2 - 0
packages/llm/llm/src/types.ts

@@ -198,6 +198,8 @@ export interface LlmProviderInfo {
   id: string
   /** Human-readable provider name for selectors and diagnostics. */
   name: string
+  /** Configuration diagnostic; the route stays registered for inspection and repair. */
+  configurationError?: string
 }
 
 /** Merge-extensible provider model modality vocabulary. */

+ 1 - 0
packages/llm/llm/tests/service.spec.ts

@@ -266,6 +266,7 @@ describe('LlmRuntime', () => {
       expect(projected, fixture.name).toMatchObject({ type: 'text' })
       if (projected?.type !== 'text') throw new Error(`expected projected text for ${fixture.name}`)
       expect(projected.text, fixture.name).toContain(fixture.expected)
+      expect(ctx.llm.fileRequestText(attachment), fixture.name).toBe(projected.text)
       if (fixture.fs !== undefined) {
         expect(projected.text, fixture.name).toContain('include this saved path in the delegation prompt')
       }

+ 2 - 2
packages/settings/settings/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/settings/settings/README.md
-README.md: 5c7313ac5015f64cca6b3d91ee75d44f27ab356c
-README.zh.md: 337effed483a1bf28d42b76e95c69bc01f805667
+README.md: 2561053b1786fe2fa7f15bbfa80345dad081fc9d
+README.zh.md: 2bfa0edc42f76962fb015606f124d07358f99e22

+ 2 - 0
packages/settings/settings/README.md

@@ -67,6 +67,8 @@ Literal namespace arguments are checked by TypeScript against the lowercase lett
 
 Every write rejects non-JSON-compatible data (a `Date`, `Map`, `BigInt`, non-finite number, or circular reference fails with its `$`-rooted path before anything persists), rejects on a read-only provider, and accepts an optional `expectedRevision`: pass back the `revision` from a descriptor, and a namespace that moved past it refuses the write with `SettingsConflictError` instead of overwriting the writer that landed first.
 
+Owners can supply `validate` for checks shared by registration, reload, and writes, plus `validateWrite(next, previous)` for checks that apply only to in-process writes. Both arguments of the write check are resolved values, including composition defaults; the previous value is the current committed snapshot when the queued write runs. A thrown write check prevents persistence and notifications. Consumers that retain dependency-dependent errors on read can use this hook to reject new mistakes while keeping old settings editable.
+
 ### Configuration surfaces
 
 `describe()` returns one descriptor per registered namespace: the serialized schema, the resolved value, the detached `base` and `user` layers (a field's presence in `user` marks it user-overridden), the effect timing, and the namespace's revision. Pass `redactSecrets: true` on every wire surface: it strips `role('secret')` fields from every layer and enumerates them as `{ path, set }` slots so a page can render write-only inputs without ever receiving a secret. `documentPath` and `prepareDocument()` expose the provider's user-editable file to a native editor when one exists.

+ 2 - 0
packages/settings/settings/README.zh.md

@@ -67,6 +67,8 @@ TypeScript 会按小写字母、数字与连字符文法检查字面量 namespac
 
 每次写入都会拒绝与 JSON 不兼容的数据(`Date`、`Map`、`BigInt`、非有限数或循环引用会在任何内容持久化前以 `$` 为根的路径报错)、拒绝只读提供方上的写入,并可接受可选的 `expectedRevision`:把 descriptor 中的 `revision` 传回,namespace 已越过该值时写入会被 `SettingsConflictError` 拒绝,而不是覆盖先完成写入的一方。
 
+Owner 可以用 `validate` 定义注册、重载与写入共用的校验,并用 `validateWrite(next, previous)` 定义仅用于进程内写入的校验。写入校验的两个参数都是包含组合默认值的已解析配置;前值是排队写入执行时当前已提交的快照。写入校验抛错会阻止持久化与通知。读取时保留依赖相关错误的消费者,可以通过此钩子拒绝新增错误,同时保持旧设置可编辑。
+
 ### 配置界面
 
 `describe()` 为每个已注册 namespace 返回一条 descriptor:序列化 schema、解析值、分离的 `base` 与 `user` 层(字段出现在 `user` 中即标记为用户覆盖)、生效时机与 namespace 的 revision。每个协议接口都必须传入 `redactSecrets: true`:它从每一层剥离 `role('secret')` 字段,并把它们枚举为 `{ path, set }` slot,让页面可以渲染只写输入而不接触任何机密。`documentPath` 与 `prepareDocument()` 在提供方拥有用户可编辑文件时把它暴露给原生编辑器。

+ 20 - 0
packages/settings/settings/src/index.ts

@@ -71,6 +71,14 @@ export interface SettingsRegisterOptions<T> {
    * @param value - the resolved section, schema-valid by construction.
    */
   validate?: (value: T) => void
+  /**
+   * Check an in-process write after resolution and before persistence. Stored
+   * values do not run this check on registration or external reload, allowing
+   * the owner to expose repairable configuration after dependencies change.
+   * @param value - next resolved section.
+   * @param previous - current resolved section, including the composition base.
+   */
+  validateWrite?: (value: T, previous: T) => void
 }
 
 /** One registered namespace as surfaced to configuration UIs. */
@@ -311,6 +319,7 @@ interface SettingsRegistration {
   applies: SettingsApplies
   /** Owner-supplied check for constraints the schema cannot express. */
   validate?: (value: unknown) => void
+  validateWrite?: (value: unknown, previous: unknown) => void
   resolved: unknown
   /**
    * Monotonic counter over this namespace's RAW user section — bumped by any
@@ -433,6 +442,9 @@ export abstract class SettingsProvider extends Service {
       ...options?.validate === undefined
         ? {}
         : { validate: options.validate as (value: unknown) => void },
+      ...options?.validateWrite === undefined
+        ? {}
+        : { validateWrite: options.validateWrite as (value: unknown, previous: unknown) => void },
       resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)),
       revision: 0,
       watchers: new Set(),
@@ -479,6 +491,7 @@ export abstract class SettingsProvider extends Service {
     const scope = this.register<Namespace, T>(ns, schema, {
       base: entry,
       ...hooks.validate === undefined ? {} : { validate: hooks.validate },
+      ...hooks.validateWrite === undefined ? {} : { validateWrite: hooks.validateWrite },
     })
     hooks.setSource(() => scope.get())
     this.ctx.effect(() => () => {
@@ -674,6 +687,7 @@ export abstract class SettingsProvider extends Service {
           ? snapshot
           : (snapshot['ops'] as SettingsPathOp[]).reduce(applyPathOp, current)
       const next = deepFreeze(this.resolve(registration.schema, registration.base, section, registration.validate))
+      registration.validateWrite?.(next, registration.resolved)
       await this.persist(ns, section)
       // The write reached storage either way; the cache must say so. Commit
       // only when this registration is still the namespace owner — a fiber
@@ -888,6 +902,12 @@ export interface SettingsSectionHooks<T> {
    * @param value - the resolved section, schema-valid by construction.
    */
   validate?: (value: T) => void
+  /**
+   * Validate only in-process writes; see {@link SettingsRegisterOptions.validateWrite}.
+   * @param value - next resolved section.
+   * @param previous - current resolved section.
+   */
+  validateWrite?: (value: T, previous: T) => void
 }
 
 export default SettingsProvider

+ 29 - 0
packages/settings/settings/tests/settings.spec.ts

@@ -84,6 +84,35 @@ describe('settings namespace validation', () => {
 })
 
 describe('registration', () => {
+  it('runs write-only validation before persistence while retaining repairable stored values', async () => {
+    const { ctx, provider } = await boot({ doc: { 'ui-theme': { fontSize: 27 } } })
+    const validateWrite = vi.fn((next: ThemeConfig, _previous: ThemeConfig) => {
+      if (next.fontSize > 16) throw new Error('font size exceeds current limit')
+    })
+    try {
+      const scope = ctx.settings.register('ui-theme', ThemeSchema, { base: { theme: 'light' }, validateWrite })
+      expect(scope.get()).toEqual({ theme: 'light', fontSize: 27 })
+      expect(validateWrite).not.toHaveBeenCalled()
+      await expect(scope.update({ fontSize: 28 })).rejects.toThrow('current limit')
+      expect(provider.persisted).toEqual([])
+      expect(scope.get().fontSize).toBe(27)
+      provider.pushExternal({ 'ui-theme': { fontSize: 29 } })
+      expect(scope.get().fontSize).toBe(29)
+      expect(validateWrite).toHaveBeenCalledTimes(1)
+      await ctx.settings.mutate('ui-theme', [{ op: 'set', path: ['fontSize'], value: 16 }])
+      expect(validateWrite).toHaveBeenLastCalledWith(
+        { theme: 'light', fontSize: 16 }, { theme: 'light', fontSize: 29 },
+      )
+      await scope.replace({ fontSize: 15 })
+      expect(validateWrite).toHaveBeenLastCalledWith(
+        { theme: 'light', fontSize: 15 }, { theme: 'light', fontSize: 16 },
+      )
+      expect(provider.persisted).toHaveLength(2)
+    } finally {
+      await ctx.fiber.dispose()
+    }
+  })
+
   it('resolves schema defaults, then composition base, then the user layer', async () => {
     const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
     const scope = ctx.settings.register('ui-theme', ThemeSchema, {

+ 1 - 0
tsconfig.host.json

@@ -33,6 +33,7 @@
     "apps/web/tests/plugin-config.e2e.ts",
     "apps/web/tests/settings-chrome.e2e.ts",
     "apps/web/tests/models-settings.e2e.ts",
+    "apps/web/tests/models-settings-recovery.e2e.ts",
     "apps/web/tests/onboarding-deepseek-config.e2e.ts",
     "apps/web/tests/onboarding-usable-provider.e2e.ts",
     "apps/web/tests/remote-welcome.e2e.ts",