From fe03b0686df1d9513144ee2d74276529a18b0d7a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 10:28:45 +0800 Subject: [PATCH 1/5] fix(llm-pi-ai): keep catalog-invalid settings repairable --- ...-14-provider-routed-llm-adapters.i18n.yaml | 4 +- ...2026-07-14-provider-routed-llm-adapters.md | 2 +- ...6-07-14-provider-routed-llm-adapters.zh.md | 2 +- ...-pi-ai-settings-catalog-recovery.i18n.yaml | 6 + ...6-09-07-pi-ai-settings-catalog-recovery.md | 39 +++++++ ...9-07-pi-ai-settings-catalog-recovery.zh.md | 39 +++++++ .../stored-error.expected.md | 37 ++++++ .../web/tests/models-settings-recovery.e2e.ts | 89 ++++++++++++++ apps/web/tsconfig.json | 1 + docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/llm-streaming.i18n.yaml | 4 +- docs/subsystems/llm-streaming.md | 2 + docs/subsystems/llm-streaming.zh.md | 2 + docs/subsystems/settings.i18n.yaml | 4 +- docs/subsystems/settings.md | 8 ++ docs/subsystems/settings.zh.md | 8 ++ .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 + .../client/ui-settings-models/README.zh.md | 2 + .../src/client/ModelsSection.tsx | 74 +++++++----- .../ui-settings-models/src/client/store.ts | 23 ++-- .../tests/components.client.spec.tsx | 35 ++++++ .../tests/store.client.spec.ts | 10 +- .../extensions/tool-cordis/src/api-catalog.ts | 6 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 + packages/llm/llm-pi-ai/README.zh.md | 2 + packages/llm/llm-pi-ai/src/adapter.ts | 16 ++- packages/llm/llm-pi-ai/src/catalog.ts | 54 ++++++--- packages/llm/llm-pi-ai/src/config.ts | 109 +++++++++++------- packages/llm/llm-pi-ai/src/index.ts | 21 ++-- packages/llm/llm-pi-ai/src/provider.ts | 4 +- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 38 +++--- .../llm-pi-ai/tests/compat-upgrade.spec.ts | 2 +- packages/llm/llm-pi-ai/tests/config.spec.ts | 11 +- .../llm-pi-ai/tests/dynamic-config.spec.ts | 64 +++++++++- packages/llm/llm/README.i18n.yaml | 4 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/README.zh.md | 2 +- packages/llm/llm/src/index.ts | 5 +- packages/llm/llm/src/types.ts | 2 + packages/llm/llm/tests/service.spec.ts | 1 + packages/settings/settings/README.i18n.yaml | 4 +- packages/settings/settings/README.md | 2 + packages/settings/settings/README.zh.md | 2 + packages/settings/settings/src/index.ts | 20 ++++ .../settings/settings/tests/settings.spec.ts | 29 +++++ tsconfig.host.json | 1 + 50 files changed, 655 insertions(+), 157 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md create mode 100644 .agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md create mode 100644 apps/web/tests/expected/models-settings-recovery/stored-error.expected.md create mode 100644 apps/web/tests/models-settings-recovery.e2e.ts diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml index d757a5efd0..06c136e20b 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md index 4cc3cc3cdc..24d6e7e439 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md +++ b/.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. diff --git a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md b/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md index 098a6af7c9..f740468ed6 100644 --- a/.agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md +++ b/.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 重写或提供方协议矩阵。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml new file mode 100644 index 0000000000..1db4095228 --- /dev/null +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md new file mode 100644 index 0000000000..1af5a3cb5b --- /dev/null +++ b/.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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md new file mode 100644 index 0000000000..3cab1f939b --- /dev/null +++ b/.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 与两个添加控件,在不改变文件的前提下拒绝无效保存,并通过删除过期模型修复路由。已有快照测试继续负责请求冻结与回放行为。 diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md new file mode 100644 index 0000000000..31fca1116a --- /dev/null +++ b/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: 添加自定义提供方 diff --git a/apps/web/tests/models-settings-recovery.e2e.ts b/apps/web/tests/models-settings-recovery.e2e.ts new file mode 100644 index 0000000000..c8e06a5aae --- /dev/null +++ b/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 + + 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([]) + }) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 17c8670403..f25c4d6d1d 100644 --- a/apps/web/tsconfig.json +++ b/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", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7aafa5de3d..20a8b59b82 100644 --- a/docs/config-catalog.i18n.yaml +++ b/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 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 90ec60c30c..267e9866b1 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1335,7 +1335,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e5c3134561..d4676ae6ad 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1337,7 +1337,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 9b4bcd724f..007bd857a6 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/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 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 97062fb326..8544517517 100644 --- a/docs/subsystems/llm-streaming.md +++ b/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 } ``` diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 9f4dc7d32b..0036939d0a 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/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 } ``` diff --git a/docs/subsystems/settings.i18n.yaml b/docs/subsystems/settings.i18n.yaml index 94a8cc437f..464c961996 100644 --- a/docs/subsystems/settings.i18n.yaml +++ b/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 diff --git a/docs/subsystems/settings.md b/docs/subsystems/settings.md index d8e3cbc46e..7d34bfa344 100644 --- a/docs/subsystems/settings.md +++ b/docs/subsystems/settings.md @@ -46,6 +46,14 @@ interface SettingsRegisterOptions { * @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 } ``` diff --git a/docs/subsystems/settings.zh.md b/docs/subsystems/settings.zh.md index 772fd7832f..a356218754 100644 --- a/docs/subsystems/settings.zh.md +++ b/docs/subsystems/settings.zh.md @@ -46,6 +46,14 @@ interface SettingsRegisterOptions { * @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 } ``` diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index b60f05babf..102eeb21d6 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 64a295e9eb..8c852023f6 100644 --- a/packages/client/ui-settings-models/README.md +++ b/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 `_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. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index a75b6f2300..6b5fd016db 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 从设置导航打开 Models 页面,即可看到每个已配置的提供方都有一行。其配置键未在任何位置配置的整分节提供方会渲染为其展开的设置卡片而非一行,但仅限首次运行姿态,且仅持续到用户关闭该卡片为止。每一类卡片各自持有自己的展开状态,因此关掉其中一张绝不会丢弃另一张里的草稿。 +存在已存储目录错误的提供方仍显示诊断以及编辑、删除入口。添加操作只面向已注册的 settings 命名空间,因此不可用的命名空间不会留下无法打开编辑器的按钮。保存被拒绝时,编辑器保持打开并展示 Host 诊断。 + ### API 密钥 编辑卡片上的主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名。键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,从而保留提供方原生认证(例如 Bedrock 凭据链或 Vertex ADC)。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index b99767a364..9f7e25d5ca 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/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 + :

{row.entry.configurationError}

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 (
  • + {configurationError} {renderProviderEditor({ target, namespace, @@ -416,6 +421,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS : null} + {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.
    - - + {configurable.length > 0 && ( + + )} + {state.namespaces.has('llm-pi-ai') && ( + + )}
    )} diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index fd8eeaf9f9..305c583e8d 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/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 diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 6d100d800e..37cc0d832e 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -307,6 +307,41 @@ async function mountDeepSeekCard(overrides: Parameters[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() diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 7757cf3792..c963017311 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/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(value: T): RpcResponse { diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a1c333b265..e054143149 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/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 {\n base?: Partial;\n applies?: SettingsApplies;\n validate?: (value: T) => void;\n}', + declaration: 'export interface SettingsRegisterOptions {\n base?: Partial;\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 {\n setSource(current: () => T): void;\n onChange(): void;\n validate?: (value: T) => void;\n}', + declaration: 'export interface SettingsSectionHooks {\n setSource(current: () => T): void;\n onChange(): void;\n validate?: (value: T) => void;\n validateWrite?: (value: T, previous: T) => void;\n}', }, { name: 'SettingsUpdateSource', diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 02ed82def7..6c971a7aa5 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/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 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index e9a59a830e..e9013b634e 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/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. + ----- diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 67c14746d8..9c91e937ab 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/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 自身的约束错误仍会拒绝加载。 + ----- diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 41a2171752..337761d960 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/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 { - 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 { diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index af52b8a49c..d626c1ac71 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/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['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[] + /** Models that cannot be resolved, retained as diagnostics during stored-config reads. */ + modelErrors: ReadonlyMap /** * 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() + // 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() const configuredMaxTokens = new Map() - const models = entries.map((entry) => { + const resolveEntry = (entry: PiAiModelProfile): Model => { + 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[] = [] + for (const entry of entries) { + let model: Model + 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 } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index b50c448af5..25675354e0 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/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 /** * 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 = 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
  • - {configurationError} + {error} {renderProviderEditor({ target, namespace, @@ -421,7 +421,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS : null} - {configurationError} + {error} {renderSlot( 'settings.models.provider-card', { provider: row.entry, configured: row.configured, keyConfigured: keyConfiguredOf(row) }, diff --git a/packages/client/ui-settings-models/src/client/store.ts b/packages/client/ui-settings-models/src/client/store.ts index 305c583e8d..2af1d54d5c 100644 --- a/packages/client/ui-settings-models/src/client/store.ts +++ b/packages/client/ui-settings-models/src/client/store.ts @@ -30,7 +30,7 @@ export interface ProviderDirectoryEntry { readonly settingsPath: readonly string[] readonly active: boolean readonly declared?: boolean - readonly configurationError?: string + readonly error?: string } /** @@ -44,20 +44,16 @@ 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) => { - 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 }, - } - }) + 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 }, + ...entry.error === undefined ? {} : { error: entry.error }, + })) for (const provider of registered) { if (declared.has(provider.id)) continue rows.push({ @@ -66,7 +62,6 @@ export function joinProviderDirectory( settingsNs: '', settingsPath: [], active: true, - ...provider.configurationError === undefined ? {} : { configurationError: provider.configurationError }, }) } return rows diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 37cc0d832e..e8823c5eea 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -331,8 +331,8 @@ describe('ModelsSection', () => { 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 }, + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], error: failure }, ])) await mountFace(scripted) expect(screen.getByRole('alert').textContent).toBe(failure) diff --git a/packages/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index c963017311..0ce5a995a1 100644 --- a/packages/client/ui-settings-models/tests/store.client.spec.ts +++ b/packages/client/ui-settings-models/tests/store.client.spec.ts @@ -6,12 +6,14 @@ import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/ import { settingsSchema } from './settings-schema.client.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', - }]) +it.each([false, true])('retains configuration diagnostics when the route is active: %s', (active) => { + expect(joinProviderDirectory(active ? [{ id: 'openai', name: 'openai' }] : [], [{ + provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], + error: 'catalog unavailable', + }])).toEqual([{ + provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], + active, error: 'catalog unavailable', + }]) }) let nextRpc = 0 diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index a2256daf9f..1620d41648 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4465,7 +4465,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmConfigurableProvider', - declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n declared?: boolean;\n}', + declaration: 'export interface LlmConfigurableProvider {\n provider: string;\n displayName: string;\n settingsNs: string;\n settingsPath: readonly string[];\n declared?: boolean;\n error?: string;\n}', }, { name: 'LlmDiscoveredModel', @@ -4501,7 +4501,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmProviderInfo', - declaration: 'export interface LlmProviderInfo {\n id: string;\n name: string;\n configurationError?: string;\n}', + declaration: 'export interface LlmProviderInfo {\n id: string;\n name: string;\n}', }, { name: 'LlmReasoningEffortInfo', diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index d5c66a842f..51f8132299 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/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: 9ec589315c40ef1a4de90b860f13454da783a2a9 -README.zh.md: 22bde6757fba0dabf60d66751283dd261f5854da +README.md: 799c99386443922bd8d4e24d8f84a826d18ff67e +README.zh.md: 8aecbc840725e02e565f2b228444d03565d931e4 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 9ec589315c..799c993864 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -114,7 +114,7 @@ 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. During namespace registration, 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. Later external edits validate changed providers and retain the last accepted section on failure. +Settings writes strictly validate each new or changed provider after merging its composition and user layers. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first diagnostic in `LlmConfigurableProvider.error`; 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. Later external edits validate changed providers and retain the last accepted section on failure. ----- diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 22bde6757f..8aecbc8407 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -114,7 +114,7 @@ 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 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。 +Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmConfigurableProvider.error` 返回首个诊断;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。 ----- diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 337761d960..8dba978c31 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -266,12 +266,7 @@ 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. - const profile = this.current().profiles.get(provider) - return { - id: provider, - name: profile?.displayName ?? provider, - ...profile?.catalogError === undefined ? {} : { configurationError: profile.catalogError }, - } + return { id: provider, name: this.current().profiles.get(provider)?.displayName ?? provider } } override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined { diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 4e23112c78..dfa2a8159e 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -106,7 +106,6 @@ function registrationFacts(profiles: ReadonlyMap left.provider.localeCompare(right.provider)) } @@ -124,7 +123,7 @@ function directoryEntries( ): LlmConfigurableProvider[] { const catalog = new Set(catalogProviderIds()) const entries = new Map() - const declare = (provider: string, displayName: string): void => { + const declare = (provider: string, displayName: string, error?: string): void => { entries.set(provider, { provider, displayName, @@ -134,10 +133,11 @@ function directoryEntries( // narrowing a shipped provider's models stores a profile too, and that // route is still one pi-ai knows. declared: !catalog.has(provider), + ...error === undefined ? {} : { error }, }) } for (const provider of catalog) declare(provider, provider) - for (const [provider, profile] of profiles) declare(provider, profile.displayName) + for (const [provider, profile] of profiles) declare(provider, profile.displayName, profile.catalogError) return [...entries.values()] } diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index da7937567a..0c6ec7f26f 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -113,7 +113,11 @@ describe('request-level dynamic profiles', () => { + '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(ctx.llm.listProviders()).toEqual([{ id: 'openrouter', name: 'openrouter' }]) + expect(ctx.llm.listConfigurableProviders()).toContainEqual({ + provider: 'openrouter', displayName: 'openrouter', settingsNs: NS, + settingsPath: ['providers', 'openrouter'], declared: false, error: 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: [] }) @@ -128,7 +132,12 @@ describe('request-level dynamic profiles', () => { await expect(ctx.settings.update(NS, { providers: { openrouter: { displayName: 'Edited' } } })).rejects.toThrow(failure) expect(await readFile(path, 'utf8')).toBe(beforeRejected) + const diagnostics: Array = [] + ctx.on('llm/adapters-updated', () => { + diagnostics.push(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'openrouter')?.error) + }) await ctx.settings.mutate(NS, [{ op: 'set', path: ['providers', 'openrouter', 'api'], value: 'openai-completions' }]) + expect(diagnostics).toEqual([undefined]) 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' }]) @@ -143,16 +152,18 @@ describe('request-level dynamic profiles', () => { } } })) 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(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'anthropic')?.error) + .toContain('modelOverrides names "removed-model"') + expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'retired-route')?.error) + .toContain('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' }]) + expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'retired-route')).toBeUndefined() + expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'anthropic')?.error).toBeUndefined() }) it('mounts bare and dormant, then registers routes the moment settings supply providers', async () => { diff --git a/packages/llm/llm/README.i18n.yaml b/packages/llm/llm/README.i18n.yaml index 7e5d5ccc35..21ca8ed6b6 100644 --- a/packages/llm/llm/README.i18n.yaml +++ b/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: a0f79106765e17437cf6bfd2cbf91e2b0cef34a9 -README.zh.md: fe99f7680e07f1742869c12692668ae426083d60 +README.md: 5fe789f38caf8e2289930ff598868ddab69d5f76 +README.zh.md: 8fb48b01e0c7a888b48f65d189279a8e0f684264 diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index a0f7910676..5fe789f38c 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -54,13 +54,13 @@ for await (const chunk of ctx.llm.stream({ } ``` -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. +After a successful mount, `ctx.llm.listProviders()` reports the registered routes in registration order. ### What you can do - **Stream one model call** — `ctx.llm.stream(options)` yields raw chunks (token-level deltas) for any registered provider and model; consumers assemble them with `BlockAssembler`. - **Register provider adapters** — an adapter owns one or more provider routes, and its registration captures that route's retry policy; registering the same route twice fails with `DUPLICATE_ADAPTER`. -- **Expose and activate providers through configuration** — adapters declare configurable-provider routes plus a settings namespace, so configuration surfaces can activate dormant providers and edit connection facts without a restart. +- **Expose and activate providers through configuration** — adapters declare configurable-provider routes plus a settings namespace, so configuration surfaces can activate dormant providers and edit connection facts without a restart. `LlmConfigurableProvider.error` reports a configuration diagnostic for repair; unaffected models can remain serviceable. - **Discover and resolve models** — list the models an adapter advertises, interrogate an endpoint for the models it serves, and resolve one exact model's context window, output default, reasoning efforts, and input modalities. - **Validate call config** — an explicit or configured reasoning effort is checked against the exact model before any provider I/O, and an adapter-configured output cap is materialized when the request omits one. - **Read an embedded Assistant stream without expanding it** — `assistantStreamFirstTokenTime` (first token), `assistantStreamHasVisibleContent` (any visible content), and `assistantStreamHasVisibleText` (any visible text) answer their questions from the compact records with early exit; `lastAssistantStreamChunk` scans backward to the last raw chunk of one type, `assistantStreamChunks` and `joinAssistantStreamText` scan the whole stream, and `assembleAssistantStream` feeds a `BlockAssembler` one joined delta per run with the same blocks, usage, and replay state as the per-member expansion. `runFirstTokenTime` and `runFirstVisibleTime` do the early-exit scan for one packed run, and `isTokenDelta`, `isVisibleChunk`, and `chunkHasVisibleText` define the token and visibility rules for a single chunk. `expandAssistantStream` remains the validating path for records read at a durable boundary; it is not memoized, because a retained expansion costs roughly ten times the compact stream for as long as the event lives. diff --git a/packages/llm/llm/README.zh.md b/packages/llm/llm/README.zh.md index fe99f7680e..8fb48b01e0 100644 --- a/packages/llm/llm/README.zh.md +++ b/packages/llm/llm/README.zh.md @@ -54,13 +54,13 @@ for await (const chunk of ctx.llm.stream({ } ``` -挂载成功后,`ctx.llm.listProviders()` 会按注册顺序报告已注册路由。适配器可以在提供方元数据中携带 `configurationError`,保留错误配置的可见性以供修复;此诊断不会注销路由。 +挂载成功后,`ctx.llm.listProviders()` 会按注册顺序报告已注册路由。 ### 你可以做什么 - **流式发起一次模型调用**——`ctx.llm.stream(options)` 为任何已注册提供方与模型产出原始分片(token 级增量);消费方用 `BlockAssembler` 组装。 - **注册提供方适配器**——一个适配器拥有一个或多个提供方路由,其注册会捕获该路由的重试策略;重复注册同一路由会以 `DUPLICATE_ADAPTER` 失败。 -- **通过配置暴露并激活提供方**——适配器声明可配置提供方路由与 settings namespace,配置界面因此可以激活休眠提供方并编辑连接事实,无需重启。 +- **通过配置暴露并激活提供方**——适配器声明可配置提供方路由与 settings namespace,配置界面因此可以激活休眠提供方并编辑连接事实,无需重启。`LlmConfigurableProvider.error` 报告供修复的配置诊断;未受影响的模型仍可提供服务。 - **发现与解析模型**——列出适配器公布的模型、询问端点它提供哪些模型,并解析某个精确模型的上下文窗口、输出默认值、推理(reasoning)强度与输入模态。 - **校验调用配置**——显式或配置的推理强度会在任何提供方 I/O 之前对照精确模型校验;请求省略输出上限时,会填入适配器配置的输出上限。 - **不展开即读取内嵌 Assistant 流**——`assistantStreamFirstTokenTime`(首 token)、`assistantStreamHasVisibleContent`(任一可见内容)与 `assistantStreamHasVisibleText`(任一可见文本)以提前退出从紧凑记录回答各自的问题;`lastAssistantStreamChunk` 反向扫描到某一类型的最后一个原始 chunk,`assistantStreamChunks` 与 `joinAssistantStreamText` 扫描整个流,`assembleAssistantStream` 向 `BlockAssembler` 每个 run 喂一段拼接 delta,blocks/usage/replayState 与逐成员展开相同。`runFirstTokenTime` 与 `runFirstVisibleTime` 对单个打包 run 做提前退出扫描,`isTokenDelta`、`isVisibleChunk` 与 `chunkHasVisibleText` 定义单个 chunk 的 token 与可见性规则。`expandAssistantStream` 仍是持久边界读取记录的校验路径;它不被记忆化,因为保留的展开在事件生命周期内约花费紧凑流的十倍内存。 diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 85ec9c996f..6a3cc5de71 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -434,10 +434,7 @@ export class LlmRuntime extends TypertRemoteService { ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`) registrations.push({ adapter, - provider: { - id: info.id, name: info.name, - ...info.configurationError === undefined ? {} : { configurationError: info.configurationError }, - }, + provider: { id: info.id, name: info.name }, retryPolicy, }) } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index d533180f18..72bae5858b 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -198,8 +198,6 @@ 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. */ @@ -238,6 +236,8 @@ export interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ + error?: string } /** diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 49fea78865..2bfcd0f486 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -266,7 +266,6 @@ 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') } From 8dc30d270c4757571f30ebecad78d15d7530cb83 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 12:04:18 +0800 Subject: [PATCH 4/5] test(llm-pi-ai): cover recovery diagnostics and document repair limits --- ...-pi-ai-settings-catalog-recovery.i18n.yaml | 4 ++-- ...6-09-07-pi-ai-settings-catalog-recovery.md | 4 ++-- ...9-07-pi-ai-settings-catalog-recovery.zh.md | 6 +++--- .../tests/components.client.spec.tsx | 20 +++++++++++++++++++ packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 ++ packages/llm/llm-pi-ai/README.zh.md | 2 ++ .../llm-pi-ai/tests/dynamic-config.spec.ts | 8 +++++--- 8 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml index 0213917daa..a0173208ab 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.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/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md -2026-09-07-pi-ai-settings-catalog-recovery.md: 13c23233e924b6ba2e0b22381e791c5ccdcdc254 -2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 6f001f545108aa2006491127318ae3123722364f +2026-09-07-pi-ai-settings-catalog-recovery.md: 7b9fba06f6d5447654420a5912e45d1684ec5041 +2026-09-07-pi-ai-settings-catalog-recovery.zh.md: d68a36cffd240df63aeb7cbb5d1922b383360fd8 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md index 13c23233e9..7b9fba06f6 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md @@ -14,7 +14,7 @@ The pi-ai consumer uses the existing settings `validate` callback. During namesp Initial profile resolution retains catalog diagnostics, 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. +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. When route-wide validation aborts catalog resolution, the incomplete catalog and its collected per-model diagnostics are discarded; model requests on that route report the route-level error. 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. `LlmConfigurableProvider.error` carries the first diagnostic for the provider row. The configurable-provider directory publishes diagnostic changes so configuration repair refreshes the browser without re-registering the adapter. 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. @@ -32,7 +32,7 @@ This extends the [provider-routed adapter decision](../architecture/2026-07-14-p ## 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. No settings API, storage format, or session event is added; configurable-provider entries gain one optional diagnostic field. +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. Models settings does not explain namespace registration failures; those errors require inspecting the configuration and startup diagnostics. No settings API, storage format, or session event is added; configurable-provider entries gain one optional diagnostic field. ## Testing diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md index 6f001f5451..d68a36cffd 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md @@ -2,7 +2,7 @@ Status: implemented -English | [中文](2026-09-07-pi-ai-settings-catalog-recovery.md) +[English](2026-09-07-pi-ai-settings-catalog-recovery.md) | 中文 ## Problem @@ -14,7 +14,7 @@ pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期 首次解析 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。 -Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。 +Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。路由级校验中止目录解析时,不完整的目录及其已收集的逐模型诊断会被丢弃,该路由上的模型请求统一报告路由级错误。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。 `LlmConfigurableProvider.error` 为提供方行携带首个诊断。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。 @@ -32,7 +32,7 @@ Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆 ## Consequences -依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。 +依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。模型设置页不展示命名空间注册失败的原因,此类错误需要检查配置与启动诊断。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。 ## Testing diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index e8823c5eea..0d72c77713 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -348,6 +348,26 @@ describe('ModelsSection', () => { expect(document.body.textContent).toBe('') }) + it('shows a configuration diagnostic inside the first-run setup card', async () => { + const scripted = scriptedFace() + const failure = 'The provider configuration needs repair' + scripted.face.llm.listProviders.mockResolvedValue(remoteOk([ + { id: 'deepseek-official', name: 'DeepSeek' }, + ])) + scripted.face.llm.listConfigurableProviders.mockResolvedValue(remoteOk([ + { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], error: failure }, + ])) + scripted.face.credentials.describe.mockResolvedValue(remoteOk({ + DEEPSEEK_API_KEY: { configured: false, writable: true }, + })) + await mountFace(scripted) + + const card = screen.getByRole('listitem') + expect(within(card).getByRole('alert').textContent).toBe(failure) + expect(within(card).getByLabelText(en.keyInput)).toBeTruthy() + expect(within(card).queryByRole('button', { name: deepSeekCopy(en.editProvider) })).toBeNull() + }) + it('dispatches the provider-card seat per rendered row, keyed by the owning namespace', async () => { const { renderSlot } = await mountSection() const cards = cardSeatCalls(renderSlot) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 51f8132299..5ea2ba0ea1 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/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: 799c99386443922bd8d4e24d8f84a826d18ff67e -README.zh.md: 8aecbc840725e02e565f2b228444d03565d931e4 +README.md: bef10768bfa758a29781b6eae0bc4c52ada8eba0 +README.zh.md: 870cf78f485de57d50f8a4e70f821022b1686ddd diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 799c993864..bef10768bf 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -116,6 +116,8 @@ A route pi-ai does not ship needs `api`, `baseURL`, and a non-empty `models` lis Settings writes strictly validate each new or changed provider after merging its composition and user layers. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first diagnostic in `LlmConfigurableProvider.error`; 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. Later external edits validate changed providers and retain the last accepted section on failure. +Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently. + ----- diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 8aecbc8407..870cf78f48 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -116,6 +116,8 @@ pi-ai 不提供的路由需要 `api`、`baseURL` 与非空 `models` 列表;无 Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmConfigurableProvider.error` 返回首个诊断;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。 +只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。 + ----- diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index 0c6ec7f26f..ad6eddf547 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -75,7 +75,8 @@ describe('login flows in a real composition', () => { }) describe('request-level dynamic profiles', () => { - it('retains the last accepted profiles after an invalid external edit and accepts a repaired file', async () => { + // Real filesystem notifications can lag behind chokidar's stability window on busy hosts. + it('retains the last accepted profiles after an invalid external edit and accepts a repaired file', { timeout: 30_000 }, async () => { const dir = await home() const path = join(dir, 'settings.yaml') await writeFile(path, JSON.stringify({ [NS]: { providers: { deepseek: {} } } })) @@ -83,14 +84,15 @@ describe('request-level dynamic profiles', () => { await writeFile(path, JSON.stringify({ [NS]: { providers: { openrouter: { models: [{ id: '111' }] } } } })) // The raw section proves the watcher processed the edit even though validation kept the old resolved value. - await expect.poll(() => ctx.settings.describe().find(section => section.ns === NS)?.user) + await expect.poll(() => ctx.settings.describe().find(section => section.ns === NS)?.user, { timeout: 10_000 }) .toEqual({ providers: { openrouter: { models: [{ id: '111' }] } } }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'deepseek' }]) await writeFile(path, JSON.stringify({ [NS]: { providers: { openrouter: { api: 'openai-completions', models: [{ id: '111' }] }, } } })) - await expect.poll(() => ctx.llm.listProviders()).toEqual([{ id: 'openrouter', name: 'openrouter' }]) + await expect.poll(() => ctx.llm.listProviders(), { timeout: 10_000 }) + .toEqual([{ id: 'openrouter', name: 'openrouter' }]) expect((await ctx.llm.listModels('openrouter')).map(model => model.id)).toEqual(['111']) }) From da313e4fdc0681318a54be7b9733c977e3b0ca97 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 8 Sep 2026 13:35:09 +0800 Subject: [PATCH 5/5] fix(llm-pi-ai): preserve actionable model diagnostics --- ...6-09-07-pi-ai-settings-catalog-recovery.i18n.yaml | 4 ++-- .../2026-09-07-pi-ai-settings-catalog-recovery.md | 2 +- .../2026-09-07-pi-ai-settings-catalog-recovery.zh.md | 2 +- .../stored-error.expected.md | 5 +++++ apps/web/tests/models-settings-recovery.e2e.ts | 7 ++++++- packages/llm/llm-pi-ai/README.i18n.yaml | 4 ++-- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/config.ts | 6 +++--- packages/llm/llm-pi-ai/tests/catalog.spec.ts | 12 ++++++++++++ 10 files changed, 34 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml index a0173208ab..43f6f3b14c 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.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/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md -2026-09-07-pi-ai-settings-catalog-recovery.md: 7b9fba06f6d5447654420a5912e45d1684ec5041 -2026-09-07-pi-ai-settings-catalog-recovery.zh.md: d68a36cffd240df63aeb7cbb5d1922b383360fd8 +2026-09-07-pi-ai-settings-catalog-recovery.md: 21fe532a491775ff875f6b9bcb000d917a95e13c +2026-09-07-pi-ai-settings-catalog-recovery.zh.md: 80bd0758e13b767671f4cec52ee3832c9250651f diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md index 7b9fba06f6..21fe532a49 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md @@ -16,7 +16,7 @@ Initial profile resolution retains catalog diagnostics, while schema and self-co 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. When route-wide validation aborts catalog resolution, the incomplete catalog and its collected per-model diagnostics are discarded; model requests on that route report the route-level error. 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. -`LlmConfigurableProvider.error` carries the first diagnostic for the provider row. The configurable-provider directory publishes diagnostic changes so configuration repair refreshes the browser without re-registering the adapter. 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. +`LlmConfigurableProvider.error` carries the first available model diagnostic for the provider row, falling back to the route error. A provider-construction failure does not overwrite a collected model diagnostic, preserving the specific correction for a missing protocol. The configurable-provider directory publishes diagnostic changes so configuration repair refreshes the browser without re-registering the adapter. 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. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md index d68a36cffd..80bd0758e1 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md @@ -16,7 +16,7 @@ pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期 Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。路由级校验中止目录解析时,不完整的目录及其已收集的逐模型诊断会被丢弃,该路由上的模型请求统一报告路由级错误。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。 -`LlmConfigurableProvider.error` 为提供方行携带首个诊断。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。 +`LlmConfigurableProvider.error` 优先为提供方行携带首个模型诊断,无模型诊断时返回路由错误。提供方构造失败不会覆盖已收集的模型诊断,从而保留缺少协议时的具体修复提示。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。 本决策扩展了[按提供方路由的适配器决策](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):提供方所有权与请求快照不变,目录有效性不决定设置是否可管理。旧记录仍保留为路由、所有权与回放设计的依据。 diff --git a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md index 31fca1116a..6a1a3e0511 100644 --- a/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -29,6 +29,11 @@ - text: zai - button "编辑 zai": 编辑 - button "删除 zai": 删除 + - listitem: + - text: acme-gateway 自定义 + - button "编辑 acme-gateway": 编辑 + - button "删除 acme-gateway": 删除 + - alert: "llm-pi-ai: provider \"acme-gateway\" model \"custom-model\" needs an api; the installed catalog does not describe it, so set the route's api to the wire protocol its endpoint speaks" - button "添加提供方": - img - text: 添加提供方 diff --git a/apps/web/tests/models-settings-recovery.e2e.ts b/apps/web/tests/models-settings-recovery.e2e.ts index c8e06a5aae..1087f8be3d 100644 --- a/apps/web/tests/models-settings-recovery.e2e.ts +++ b/apps/web/tests/models-settings-recovery.e2e.ts @@ -14,6 +14,8 @@ 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' +const CUSTOM_FAILURE = 'llm-pi-ai: provider "acme-gateway" model "custom-model" 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 @@ -26,7 +28,8 @@ describe('web e2e: repairs a stored provider after catalog drift', () => { home = await mkdtemp(join(tmpdir(), 'dsh-models-recovery-')) await writeFile(join(home, 'settings.yaml'), [ 'llm-pi-ai:', ' providers:', ' openrouter:', ' models:', - ' - id: "111"', ' zai: {}', '', + ' - id: "111"', ' zai: {}', ' acme-gateway:', + ' baseURL: https://gateway.example/v1', ' models:', ' - id: "custom-model"', '', ].join('\n')) scaffold = await launchWebScaffold({ harnessHome: home }) browser = await chromium.launch() @@ -56,6 +59,8 @@ describe('web e2e: repairs a stored provider after catalog drift', () => { 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: '编辑 acme-gateway', exact: true }).count()).toBe(1) + expect(await dialog.getByText(CUSTOM_FAILURE, { 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()) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 5ea2ba0ea1..200c601464 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/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: bef10768bfa758a29781b6eae0bc4c52ada8eba0 -README.zh.md: 870cf78f485de57d50f8a4e70f821022b1686ddd +README.md: 9e534716f80e4246467de31ab43fb9a7c6381d28 +README.zh.md: 820b69fe6d390aee530ed2da3f5c67f27311e8de diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index bef10768bf..9e534716f8 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -114,7 +114,7 @@ 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. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first diagnostic in `LlmConfigurableProvider.error`; 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. Later external edits validate changed providers and retain the last accepted section on failure. +Settings writes strictly validate each new or changed provider after merging its composition and user layers. During namespace registration, stored catalog failures retain the namespace and provider rows, with the first available model diagnostic or route failure in `LlmConfigurableProvider.error`; 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. Later external edits validate changed providers and retain the last accepted section on failure. Changing `displayName`, `apiKeyEnv`, or `baseURL` without resolving the provider's model errors still rejects the save. For example, renaming an OpenRouter route whose model `111` needs an `api` cannot be saved on its own: repair or remove that model in the same editor draft, then save the complete provider configuration. Intermediate repairs remain in the draft until the whole provider validates; other providers can be saved independently. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 870cf78f48..820b69fe6d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -114,7 +114,7 @@ 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 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmConfigurableProvider.error` 返回首个诊断;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。 +Settings 写入会在合并组合层与用户层后严格校验每个新增或修改的提供方。命名空间注册时,已存储配置的目录解析错误会保留命名空间与提供方行,并通过 `LlmConfigurableProvider.error` 优先返回首个模型诊断,无模型诊断时返回路由错误;未修改的错误提供方不会阻止其他编辑。可解析的模型仍可选择,无法解析的模型保留在可编辑配置中,直接请求时会在网络 I/O 前以 `INVALID_CONFIG` 失败。修复或删除错误配置会清除诊断。Schema 与 profile 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。 只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。 diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index 34516503a2..2f9e563008 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -205,7 +205,7 @@ export interface ResolvedPiAiProviderProfile * a stored route cannot be constructed; its configuration remains editable. */ piProvider?: Provider - /** First stored-catalog diagnostic for settings surfaces; absent for a serviceable route. */ + /** First model diagnostic, or the route failure when no model diagnostic is available. */ catalogError?: string /** Per-model failures reported before attempting a request. */ modelErrors: ReadonlyMap @@ -470,6 +470,7 @@ export function resolveProfiles( defaultContextWindow: source.defaultContextWindow ?? DEFAULT_CONTEXT_WINDOW, defaultMaxTokens: source.defaultMaxTokens ?? DEFAULT_MAX_TOKENS, }, validation) + catalogError = catalog.modelErrors.values().next().value piProvider = buildProvider({ provider, displayName, @@ -478,10 +479,9 @@ export function resolveProfiles( 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 + catalogError ??= error.message } const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source resolved.set(provider, { diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 02644744d2..934c2faf64 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -323,6 +323,18 @@ describe('hand-declared providers', () => { })).toThrow(/needs a baseURL/) }) + it('retains the missing-api model diagnostic when a stored custom provider cannot be built', () => { + const profile = resolveProfiles({ + 'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: '111' }] }, + }, 'deferred').get('acme-gateway')! + const failure = 'llm-pi-ai: provider "acme-gateway" 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(profile.catalogError).toBe(failure) + expect(profile.modelErrors.get('111')).toBe(failure) + expect(profile.piProvider).toBeUndefined() + }) + it.each(['bedrock-converse-stream', 'google-vertex', 'azure-openai-responses', 'openai-codex-responses'])( 'refuses %s, whose authentication a profile cannot express', (api) => {