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..43f6f3b14c --- /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: 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 new file mode 100644 index 0000000000..21fe532a49 --- /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 + +The pi-ai consumer uses the existing settings `validate` callback. During namespace registration it tolerates catalog diagnostics; after registration it strictly checks changed providers against the current resolved section. Settings invokes this callback before persistence for update, replacement, and path mutation. External reload uses the same strict check and retains the last accepted section on failure. The settings service and its public API remain unchanged. + +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. 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 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. + +## 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. 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 + +Adapter tests cover mixed valid/invalid models, deleted override referents, independent provider edits, route deletion, pre-network failure, and repair. A file-watcher regression verifies that invalid external edits retain the last accepted profiles and a repaired file takes effect. 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..80bd0758e1 --- /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 + +pi-ai 消费者使用现有 settings `validate` 回调。命名空间注册期间容忍目录诊断;注册完成后,将变化的提供方与当前已解析分节比较并严格校验。Settings 在更新、整体替换和路径修改的持久化之前调用此回调。外部重载使用相同的严格校验,失败时保留最后一次接受的分节。Settings 服务及其公共 API 保持不变。 + +首次解析 profile 时保留目录诊断,而 schema 与 profile 自身的约束仍会拒绝加载。写入会将有效提供方值与已提交快照比较,严格解析每个新增或修改的提供方。未修改的错误提供方不会阻止其他提供方编辑,删除仍然可用。修改提供方级设置会校验其影响的所有模型。 + +Profile 解析在有效模型旁保留逐模型错误。失去引用目标的覆盖会保留诊断,而不会禁用其余目录。路由级目录失败会保留提供方与可编辑设置,但不提供可调用模型。路由级校验中止目录解析时,不完整的目录及其已收集的逐模型诊断会被丢弃,该路由上的模型请求统一报告路由级错误。适配器在解析凭据和网络 I/O 前检查所选模型已记录的错误,并报告 `INVALID_CONFIG`。加载过程不会猜测协议或改写用户配置。不可变快照仍保证进行中的请求使用其捕获的配置。 + +`LlmConfigurableProvider.error` 优先为提供方行携带首个模型诊断,无模型诊断时返回路由错误。提供方构造失败不会覆盖已收集的模型诊断,从而保留缺少协议时的具体修复提示。可配置提供方目录发布诊断变化,修复配置会刷新浏览器,无需重新注册适配器。错误模型 ID 保留在设置中,模型选择器只接收可服务条目。模型设置页显示诊断并保留编辑、删除控件。两个添加操作都要求其所属 settings 命名空间存在;普通添加菜单会过滤不可用的命名空间。 + +本决策扩展了[按提供方路由的适配器决策](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):提供方所有权与请求快照不变,目录有效性不决定设置是否可管理。旧记录仍保留为路由、所有权与回放设计的依据。 + +## Alternatives considered + +**在注册时拒绝目录错误。** 这会阻止用户修复结构可解析的配置,并让未使用的过期模型禁用无关提供方。 + +**同时放宽保存校验。** 对无法推断协议的新模型,可以立即拒绝并点名提供方与模型。接受它只会制造可避免的请求时错误。 + +**每次保存都严格重校验整个命名空间。** 无关提供方的旧错误会阻止添加正常提供方,或逐个修复提供方。 + +**为 OpenRouter 指定固定路由协议。** 路由覆盖会替换所有模型的协议,可能改变有意使用其他 API 的正常目录条目。 + +## Consequences + +依赖升级产生的错误仍可见、可修复,而新增提供方编辑的校验不会放宽。配置错误与远端模型是否存在仍然不同:显式指定协议的目录外 ID 可以被接受,由端点决定该 ID 是否存在。标量或文档错误仍尽早失败。模型设置页不展示命名空间注册失败的原因,此类错误需要检查配置与启动诊断。不增加 Settings API、存储格式或 session 事件;可配置提供方条目新增一个可选诊断字段。 + +## Testing + +适配器测试覆盖有效与错误模型混合、覆盖目标被删除、独立提供方编辑、路由删除、联网前失败及修复。文件监听回归验证非法外部编辑保留最后一次接受的 profile,而修复后的文件能够生效。完整 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..6a1a3e0511 --- /dev/null +++ b/apps/web/tests/expected/models-settings-recovery/stored-error.expected.md @@ -0,0 +1,42 @@ +- 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": 删除 + - 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: 添加提供方 + - 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..1087f8be3d --- /dev/null +++ b/apps/web/tests/models-settings-recovery.e2e.ts @@ -0,0 +1,94 @@ +/** 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' +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 + 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: {}', ' acme-gateway:', + ' baseURL: https://gateway.example/v1', ' models:', ' - id: "custom-model"', '', + ].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: '编辑 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()) + + 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 741fa7f70f..2a25226d5f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -43,6 +43,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 bc3c86070c..2c01491512 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: 81281af98001149f88896b490bc261f1c1fc0d26 -config-catalog.zh.md: e0b5156b52f67a0cf99688fe1f61feff5419b2f0 +config-catalog.md: a1cc7d72898367cb4a752c10355d5c4808d1c6c7 +config-catalog.zh.md: 88e656f0ac0a8132638259e0ab089888b633d446 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 81281af980..a1cc7d7289 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1367,7 +1367,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e0b5156b52..88e656f0ac 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1369,7 +1369,7 @@ export type PiAiThinkingTokenBudgetField = NonNullable diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index d59028fa8b..ff5cdfed88 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: 35a4db80d17205f91342523c8413054743fb7481 -llm-streaming.zh.md: 5e3ddc55ab2972e3e2a318baf1e37e0013d324e8 +llm-streaming.md: cadfda5553ca5d5e0df78f40b6a7a75ef7cc71a6 +llm-streaming.zh.md: 5f73d544459268ce83a30218b710dd7c29f7a614 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 35a4db80d1..cadfda5553 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -492,6 +492,8 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ + error?: string } ``` diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 5e3ddc55ab..5f73d54445 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -498,6 +498,8 @@ interface LlmConfigurableProvider { * from outside. */ declared?: boolean + /** Configuration diagnostic for repair; unaffected models may remain serviceable. */ + error?: string } ``` 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..54b0610888 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 error = row.entry.error === undefined + ? null + :

{row.entry.error}

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 (
  • + {error} {renderProviderEditor({ target, namespace, @@ -416,6 +421,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS : null} + {error} {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..2af1d54d5c 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 error?: string } /** @@ -51,6 +52,7 @@ export function joinProviderDirectory( 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 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..0d72c77713 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -307,12 +307,67 @@ 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.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) + 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() 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/client/ui-settings-models/tests/store.client.spec.ts b/packages/client/ui-settings-models/tests/store.client.spec.ts index 7757cf3792..0ce5a995a1 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,17 @@ 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.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 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 149a4e20d8..bf9c40d1a8 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -4477,7 +4477,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', diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 56757a1750..12266ad5e0 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: f92d377cd7f94c6a75b7b1263e0b02f7526a9741 -README.zh.md: 58bf08adc9a71bbe22f534db370ae3b237ffaf32 +README.md: 7f52d3f04634c805fc69b4c00365b9843d308252 +README.zh.md: 7916d2e6e03c163f9f8031344ff8aa36cfff379e diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index f92d377cd7..7f52d3f046 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -114,6 +114,10 @@ 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 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 58bf08adc9..7916d2e6e0 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -114,6 +114,10 @@ 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 自身的约束错误仍会拒绝加载。后续外部文件编辑会校验变化的提供方,失败时保留最后一次接受的分节。 + +只修改 `displayName`、`apiKeyEnv` 或 `baseURL` 而未解决提供方的模型配置错误时,保存仍会被拒绝。例如,OpenRouter 路由的模型 `111` 缺少 `api` 时,不能单独保存路由名称的修改:需要在同一份编辑草稿中修复或删除该模型,再保存完整的提供方配置。中间修复状态保留在草稿中,直到整条提供方配置通过校验;其他提供方可以独立保存。 + ----- diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 41a2171752..8dba978c31 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') 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..2f9e563008 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 model diagnostic, or the route failure when no model diagnostic is available. */ + 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,17 @@ 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) } /** Reject removed pre-release profile fields and name their replacements. */ @@ -394,15 +396,16 @@ function assertValidHeaders(provider: string, headers: Readonly