From af652c949f24a0920230e8a7455878f416106b75 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 12:07:28 +0800 Subject: [PATCH 1/5] fix(web): recover provider credential lifecycle --- .../2026-07-30-web-config-plane.i18n.yaml | 4 +- .../2026-07-30-web-config-plane.md | 4 +- .../2026-07-30-web-config-plane.zh.md | 4 +- ...06-provider-credential-lifecycle.i18n.yaml | 6 + ...026-08-06-provider-credential-lifecycle.md | 27 +++ ...-08-06-provider-credential-lifecycle.zh.md | 27 +++ apps/web/tests/models-settings.e2e.ts | 56 ++++-- .../models-settings/configured.expected.md | 4 +- .../models-settings/delete.expected.md | 8 +- .../models-settings/empty.expected.md | 2 +- docs/config-catalog.md | 5 +- packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 6 +- packages/client/ui-models/README.zh.md | 6 +- .../ui-models/src/client/ModelsSection.tsx | 90 ++++++--- .../ui-models/src/client/ProviderEditor.tsx | 48 +++-- .../client/ui-models/src/client/locales.ts | 24 ++- packages/client/ui-models/src/client/store.ts | 12 -- packages/client/ui-models/tests/apply.spec.ts | 6 +- .../ui-models/tests/components.spec.tsx | 178 ++++++++++++++---- packages/llm/llm-deepseek/README.i18n.yaml | 4 +- packages/llm/llm-deepseek/README.md | 2 +- packages/llm/llm-deepseek/README.zh.md | 2 +- packages/llm/llm-deepseek/src/index.ts | 8 +- .../llm/llm-deepseek/tests/adapter.spec.ts | 7 + 25 files changed, 400 insertions(+), 144 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml index 647e4649d0..8ec7ff129e 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.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-30-web-config-plane.md -2026-07-30-web-config-plane.md: 5225460be1d66b85a05ff2fd5ae2826b0e6c41d7 -2026-07-30-web-config-plane.zh.md: 53a21ddf31640d963c413e1793276de694547311 +2026-07-30-web-config-plane.md: 11554077d1848dcdf59b896dd9c29a39fd2f55d4 +2026-07-30-web-config-plane.zh.md: 527c2de8155a56789358b801f9c374e16c81931b diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md index 5225460be1..11554077d1 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md @@ -22,7 +22,7 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer **A hand-written editor over a schema model layer.** `dsh-client-schema-form` rehydrates the wire's `toJSON()` envelope into live schemastery nodes for validation, path resolution, and immutable draft editing — but no generic rendering: the first cut shipped a full schema-driven form renderer, and the resulting page was an unstyled schema dump (every advanced field flattened onto the card, raw field names as labels, the `retryPolicy` unsupported-fallback in the main flow). The user chose the hand-written direction over adding a hint/grouping system, and a second round removed the reference input entirely: the card's primary field is one **API key** input, a whole-section provider without a configured key opens as its setup card, and the collapsed 自定义设置 fold carries the curated per-family extras (`baseURL` for both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, plus direct DeepSeek model rows with `id`, `name`, and `contextWindow`). Existing model fields outside that visible set survive array edits; retry policy, timeouts, and other fields remain owned by `settings.yaml`. Validation still runs the rehydrated schema before writing, while adapter-specific checks reject catalog invariants that the serialized schema cannot express. The card's colors resolve through the `--dsw-alias-*` design tokens; it had named `--border`/`--surface`/`--text-*`, which nothing in this app defines, so it rendered their light-mode fallbacks and stayed light under the dark theme. The model catalog takes the row shape the pi-ai provider form introduces: one bordered entry per model, id and display name on the row, and the capacities behind the row's own disclosure, so the two editors read as one design rather than diverging once both land. Every field keeps the indexed `aria-label` that names it. Both capacities are text fields reading a decimal `K`/`M` suffix (`1M` is 1000K, matching how capacities are quoted) and storing the plain count: a field holds the typed text while it has focus, because re-deriving it from the parsed count on every keystroke would rewrite `1000` to `1K` mid-word, and text that does not parse stays on screen so the save-time rejection names a row the user can still see. The shared class names carry this file's token spellings, not that branch's: `--dsw-alias-border-subtle`, `--dsw-alias-text-tertiary`, and `--dsw-alias-text-primary` are undeclared, so they resolve to the light-mode literals in their fallback slots — the defect this section was moved off. A styles test now rejects any `--dsw-*` name the token sheet does not declare, so the next editor to name one fails rather than shipping a light-only surface. -**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation), so `settings.yaml` never carries a key value. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized model-provider confirmation dialog; cancellation, its close button, and its mask leave the profile untouched, while the destructive confirmation submits the single unset and blocks duplicate submission until it settles. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. +**The Models page is a three-domain join with seam-shaped apply semantics.** Rows are configured providers; the add card's select is the dormant directory remainder. Route liveness still gates readiness and invalidates the join, but the page does not render it as provider status because configuration presence and runtime availability are distinct. The key path stays reference-shaped without ever showing a reference: a typed key stores **write-only** through `credentials.set` under the profile's `apiKeyEnv`, deriving `_API_KEY` when none exists (the pi-ai profile records the derivation only when a key is entered), so `settings.yaml` never carries a key value; a blank pi-ai key materializes a reference-free profile and preserves provider-native authentication. Profile edits and removals land as minimal path-addressed `settings.mutate` operations against the redacted user section, which never names a secret the page did not receive. Removing a user-layer provider first opens a localized confirmation dialog whose row actions, title, description, and final action identify the same provider; confirmation removes an exact configured+writable derived credential before the profile, while custom, environment, and unidentified targets remain untouched. Both stages are idempotent and a partial failure stays in the dialog for retry. DeepSeek's model list is array-replace configuration: inherited effective rows remain visible until the first edit materializes the complete list in the user layer, and reset unsets the list override. The partial-commit and credential-ownership rationale lives in the [provider credential lifecycle note](../bug-fix/2026-08-06-provider-credential-lifecycle.md). ## Alternatives considered @@ -36,4 +36,4 @@ PR1 made LLM adapter configuration restart-free at the seam, but the only writer ## Consequences -The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves the profile intact, confirmation removes it, and the intentionally retained credential survives. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models), a page address for live routes that never declared configurability, and explicit removal of a provider's retained credential. +The whole loop is pinned keyless in the browser lane (`apps/web/tests/models-settings.e2e.ts`): the add card offers the dormant pi-ai catalog, adding `minimax-cn` with a typed key writes the reference-only profile into `settings.yaml`, stores the value into the harness home's `.env` under the derived `MINIMAX_CN_API_KEY`, registers the route live on the topology frame, and the customized fold merges `reasoning` beside the reference — zero model calls, ARIA goldens for the add-card, configured, and identified delete-confirmation states, plus a scaffold `harnessHome` so tests never touch a real `~/.dsh` (the provider under test is one whose derived reference cannot collide with a developer's exported keys). The settings-shell scenario intercepts the pathless native intent; seam, provider, wire, React, and native-opener tests separately pin provider absence, custom-path resolution, absent-file materialization, owner-only permissions, hidden remote/unavailable states, duplicate-click collapse, localized failure, macOS text-editor dispatch, and Linux/Windows desktop dispatch. The removal scenario proves cancellation leaves both profile and key intact, then confirmation removes both the profile and its identified managed credential. The DeepSeek onboarding fixture edits the default catalog into a user-owned list, persists an arbitrary model id/name/context window, removes the active row, and observes the model selector's empty-selection fallback. The rename touched 239 files (fixtures, goldens, docs, python) in one commit with no compatibility alias. The renderer replacement cost one commit and no wire change: apply semantics, redaction, and the directory join were renderer-agnostic all along. Deferred: a per-row models preview (the picker already lists models) and a page address for live routes that never declared configurability. diff --git a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md index 53a21ddf31..527c2de815 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-web-config-plane.zh.md @@ -22,7 +22,7 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 **架在 schema 模型层之上的手写编辑器。**`dsh-client-schema-form` 把 wire 的 `toJSON()` 信封还原(rehydrate)为活的 schemastery 节点,用于校验、路径解析与不可变草稿编辑——但不做通用渲染:第一版交付了完整的 schema 驱动表单渲染器,得到的却是一个未加样式、把 schema 原样倾倒出来的页面(每个进阶字段都平铺到卡片上、原始字段名直接充当标签、`retryPolicy` 的「不支持」回退落在主流程里)。用户没有再加一套提示/分组系统,而是选择了手写方向,第二轮又把引用输入框整个移除:卡片的主字段是一个 **API 密钥**输入框,未配置密钥的整分节提供方会以其设置卡片的形式打开,收起的「自定义设置」折叠区承载按家族精选的额外字段(两个家族都有 `baseURL`,deepseek 有 `reasoningEffort`/pi-ai 有 `reasoning`,另有直接 DeepSeek 模型行的 `id`、`name` 和 `contextWindow`)。现有模型字段中不在可见集合内的部分会在数组编辑后保留;重试策略、超时及其他字段仍归 `settings.yaml` 所有。校验仍会在写入前运行还原出的 schema,适配器特有的检查则会拒绝序列化 schema 无法表达的目录不变量。卡片的颜色经 `--dsw-alias-*` 设计 token 解析;它此前引用的 `--border`/`--surface`/`--text-*` 在本应用中无人定义,于是渲染出的是它们的亮色模式回退值,在暗色主题下依旧保持亮色。模型目录采用 pi-ai 提供方表单引入的行形态:每个模型一个带边框的条目,ID 与显示名称落在行上,容量则收在该行自己的折叠区里,使两个编辑器呈现为同一套设计,而不是在双方都落地后各自分岔。每个字段都保留那个为其命名的带序号 `aria-label`。两项容量都是文本输入框,读取十进制的 `K`/`M` 后缀(`1M` 即 1000K,与容量的通行标注方式一致)并存储纯数值:字段持有焦点期间保留键入的文本,因为若每次按键都从解析出的数值重新推导该文本,`1000` 会在尚未输完时就被改写成 `1K`;无法解析的文本也会留在屏幕上,因此保存时的拒绝点名的是用户仍能看见的那一行。共用的类名承载的是本文件的 token 写法,而非那个分支的:`--dsw-alias-border-subtle`、`--dsw-alias-text-tertiary` 和 `--dsw-alias-text-primary` 均未声明,于是它们解析为各自回退槽位中的亮色模式字面值——正是本节此前迁离的那个缺陷。现在有一个样式测试会拒绝 token 表未声明的任何 `--dsw-*` 名称,因此下一个写出这类名称的编辑者会当场失败,而不是交付一个只有亮色的界面。 -**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(pi-ai profile 会记录该派生),因此 `settings.yaml` 从不携带密钥值。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化的模型提供方确认对话框;取消操作、关闭按钮和遮罩均不会改动 profile,而破坏性确认会提交唯一一条 unset,并在其完成前阻止重复提交。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。 +**Models 页是一次三领域联接,应用语义与 seam 同形。**每一行是一个已配置的提供方;「新增」卡片的选择框是可配置提供方目录中剩余的休眠条目。路由存活状态仍用于就绪判定,并会使该联接失效,但页面不将其渲染为提供方状态,因为配置存在与运行时可用性是两个不同概念。密钥通道保持引用形态,却从不展示任何引用:键入的密钥经 `credentials.set` **只写**存入 profile 的 `apiKeyEnv` 之下,引用不存在时便派生 `_API_KEY`(仅在输入密钥时,pi-ai profile 才会记录该派生),因此 `settings.yaml` 从不携带密钥值;留空 pi-ai 密钥会具化一个不带引用的 profile,并保留提供方原生认证。profile 的编辑和删除会针对脱敏后的用户分节,以按路径寻址的最小 `settings.mutate` 操作落地,绝不会点名页面未收到的机密。删除用户层提供方时,会先打开本地化确认对话框,其行操作、标题、说明和最终操作都会点名同一个提供方;确认后会先清除与派生目标精确匹配且已配置、可写的凭据,再删除 profile,自定义目标、环境目标和无法识别的目标则保持不变。两个阶段都具备幂等性,部分失败会留在对话框中供重试。DeepSeek 的模型列表是数组替换配置:继承而来的生效模型行会一直显示,直到第一次编辑将完整列表具化到用户层;重置则会取消设置该列表覆盖。部分提交与凭据所有权的理由记录在[提供方凭据生命周期 note](../bug-fix/2026-08-06-provider-credential-lifecycle.md)中。 ## 曾考虑的替代方案 @@ -36,4 +36,4 @@ PR1 让 LLM(大语言模型)适配器配置在 seam 层面免重启,但唯 ## 后果 -整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明:取消后 profile 保持原样,确认后会将其删除,而刻意保留的凭据依然存在。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)、为从未声明可配置性的存活路由提供页面地址,以及显式删除提供方所保留的凭据。 +整条闭环以无密钥方式固定在浏览器测试通道(`apps/web/tests/models-settings.e2e.ts`):「新增」卡片提供休眠的 pi-ai catalog,携键入的密钥添加 `minimax-cn` 会把只含引用的 profile 写入 `settings.yaml`、把密钥值存入 harness 家目录 `.env` 中派生的 `MINIMAX_CN_API_KEY` 之下、路由随拓扑帧注册为存活,「自定义设置」折叠区则把 `reasoning` 合并到引用旁边——全程零模型调用,「新增」卡片态、已配置态与已点名目标的删除确认态各有 ARIA golden,另有脚手架式的 `harnessHome`,测试绝不触碰真实的 `~/.dsh`(受测提供方是派生引用不可能与开发者已导出密钥相撞的那一个)。设置外壳场景会截获无路径参数的原生意图;seam、提供方、wire、React 与原生打开器测试分别固定了提供方缺失、自定义路径解析、缺失文件创建、仅属主权限、远程/不可用时隐藏、重复点击合并、本地化失败、macOS 文本编辑器分发,以及 Linux/Windows 桌面分发。删除场景证明,取消会保留 profile 和密钥,随后的确认会同时删除 profile 及其已识别的受管凭据。DeepSeek 首次使用 fixture 会把默认目录编辑为用户自有列表、持久化任意模型的 ID/名称/上下文窗口、移除活动模型行,并观察模型选择器的空选择回退。这次重命名在一次提交中触及 239 个文件(fixture(测试前置数据)、golden、文档、python),未保留兼容别名。替换渲染器只花了一次提交,且没有任何 wire 变更:应用语义、脱敏与目录联接从一开始就与渲染器无关。延后事项:每行的模型预览(选择器已能列出模型)和为从未声明可配置性的存活路由提供页面地址。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml new file mode 100644 index 0000000000..11ba2e0744 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.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-08-06-provider-credential-lifecycle.md +2026-08-06-provider-credential-lifecycle.md: 6965d573af6989dffd7b6066fd8b3e50872a6a25 +2026-08-06-provider-credential-lifecycle.zh.md: de6f76d0725e954e27ec99062832fe40c36fcfe9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md new file mode 100644 index 0000000000..6965d573af --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md @@ -0,0 +1,27 @@ +# Agent Note: Recoverable provider credential lifecycle + +Status: implemented + +English | [中文](2026-08-06-provider-credential-lifecycle.zh.md) + +## Problem + +The Models editor spans independent settings and credential RPC domains. It previously committed provider settings before storing the API key but kept the revision and original subtree from when the card opened. If the credential write failed, retry replayed the already-committed settings mutation with a stale revision and produced a conflict, leaving the user unable to complete the second stage from the same card. A blank pi-ai key also wrote the derived `apiKeyEnv` without a credential, which prevented pi-ai from using provider-native discovery. At deletion, the inverse leak remained: the profile disappeared but its page-stored key stayed in `.env` and silently became active when the provider was added again. Generic row actions and confirmation copy did not identify which provider would be changed. + +## Decision + +Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery. + +Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. + +## Alternatives considered + +**Add a cross-domain transaction RPC.** Settings and credentials have separate owning services and durable stores; introducing a new host transaction would broaden the public wire and still require compensation for provider-specific persistence failures. The UI checkpoint makes the current ordered stages recoverable without adding a fourth configuration contract. + +**Delete every credential reference named by a removed profile.** A custom reference can be shared, externally managed, or intentionally survive profile churn. Exact equality with this page's derived target plus configured+writable state is the narrow evidence available to the page; anything weaker risks deleting a credential it does not own. + +**Remove settings first and compensate by recreating the profile.** The browser holds only a redacted subtree and cannot faithfully reconstruct stored literal secrets or concurrent edits. Credential-first deletion leaves the authoritative profile visible on partial failure and makes retry safe without synthesizing configuration. + +## Consequences + +The Models page can recover from either second-stage failure without reload, secret disclosure, or a false concurrency conflict, and blank-key pi-ai profiles preserve Bedrock, Vertex, and other provider-native authentication. Deleting a page-managed provider no longer leaves a reusable local key, while ambiguous credentials deliberately remain for manual management. Save and delete are still not atomic across durable stores: a process crash can stop between stages, but their order and idempotence leave an observable, retryable state. Component tests pin partial-success retries, empty-key native auth, normalized literals, target identity, cleanup ownership, and credential/settings rejection ordering; the keyless browser scenario pins bilingual accessible copy and verifies that confirmed deletion removes both `settings.yaml` profile and `.env` credential. This decision refines the Models apply semantics recorded in the [web configuration plane note](../architecture/2026-07-30-web-config-plane.md). diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md new file mode 100644 index 0000000000..de6f76d072 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 可恢复的提供方凭据生命周期 + +Status: implemented + +[English](2026-08-06-provider-credential-lifecycle.md) | 中文 + +## 问题 + +Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它先提交提供方 settings,再存储 API 密钥,却一直保留卡片打开时的 revision 和原始子树。如果凭据写入失败,重试会用陈旧 revision 重放已提交的 settings 变更,并产生冲突,导致用户无法从同一张卡片完成第二个阶段。空的 pi-ai 密钥还会写入派生的 `apiKeyEnv`,却不写入凭据,从而阻止 pi-ai 使用提供方原生凭据发现。删除时则存在相反的残留问题:profile 消失了,页面存储的密钥却保留在 `.env` 中,并在重新添加提供方时静默地恢复作用。笼统的行操作与确认文案也没有标明要更改哪个提供方。 + +## 决策 + +提供方保存仍在现有 wire 领域上按先 settings、后凭据的两阶段顺序执行,但卡片会把成功的 settings 响应视为提交检查点。它会在尝试 `credentials.set` 之前,用返回的脱敏 descriptor 替换比较基准子树与预期 revision;如果第二阶段失败,草稿密钥与卡片会继续显示,重试不会产生 settings op,只会再次写入凭据。首次 settings 提交之前发生的真实并发变更仍会以 `settings-conflict` 失败。UI 与 DeepSeek 直连 resolver 边界均会去除所输密钥的首尾空白,且只有标准化密钥非空时,pi-ai 才会记录派生引用;留空密钥会具化一个空的、不带引用的 profile,以便使用提供方原生凭据发现。 + +只有当联接所得的行识别出该页面派生的精确 `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。 + +## 曾考虑的替代方案 + +**添加跨领域事务 RPC。**settings 与凭据分属不同的主管服务与持久存储;引入新的 Host 事务会扩大公开 wire 面,而且仍需要补偿提供方特定的持久化失败。UI 检查点让当前的有序阶段变得可恢复,无需添加第四项配置契约。 + +**删除被移除 profile 所指定的每一个凭据引用。**自定义引用可能被共享、由外部管理,或有意在 profile 反复增删时存留。与该页面派生目标精确相等,再加上已配置且可写的状态,是页面所能获得的最小范围证据;比这更弱的判定都有可能删除不属于它的凭据。 + +**先删除 settings,再重建 profile 以作补偿。**浏览器只持有脱敏后的子树,无法忠实重建已存的字面机密或并发编辑。先删除凭据可以让权威 profile 在部分失败时仍然可见,并且无需合成配置就能安全重试。 + +## 后果 + +Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 1d9117dc85..36892f2071 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -1,15 +1,17 @@ // Web e2e scenario: the Models settings page end to end through the real -// wire — the add card offers the dormant pi-ai catalog, typing an API key +// wire — the add card offers the dormant pi-ai catalog, a blank key saves a +// reference-free profile for provider-native auth, and typing an API key later // stores it write-only under the derived reference (`MINIMAX_CN_API_KEY`) -// while the settings document records only that reference; the saved row -// appears after the route topology invalidation without presenting liveness -// as provider status. The customized-settings fold writes the curated +// while the settings document records only that reference. Each saved row +// appears after route topology invalidation without presenting liveness as +// provider status. The customized-settings fold writes the curated // reasoning field as a merge patch. Zero model calls: configuration is pure // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can // never shadow the derived reference. Removing that row is guarded by the -// localized provider-confirmation dialog before the unset reaches the wire. +// localized, identified provider-confirmation dialog before the credential +// and settings unsets reach the wire. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -75,29 +77,43 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE) }, 60_000) - it('stores the key under the derived reference and the route registers live', async () => { + it('saves a blank key as a reference-free provider-native profile', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-auth')) + const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '保存', exact: true }).click() + const row = dialog.getByText('minimax-cn', { exact: true }).first() + await row.waitFor({ timeout: 10_000 }) + const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') + expect(document).toContain('minimax-cn: {}') + expect(document).not.toContain('MINIMAX_CN_API_KEY') + }, 60_000) + + it('stores the key under the derived reference and keeps the route live', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) + await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The profile lands in settings.yaml with only the derived reference, the // key value lands in the harness home's .env, the dormant route // registers, and the topology frame invalidates the page into the row. - const row = dialog.getByText('minimax-cn', { exact: true }).first() - await row.waitFor({ timeout: 10_000 }) + await expect.poll(async () => dialog.getByLabel('API 密钥').count(), { timeout: 10_000 }).toBe(0) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') expect(document).not.toContain('sk-e2e-minimax') - const stored = await readFile(join(scaffold.harnessHome, '.env'), 'utf8') - expect(stored).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + const credentialFile = join(scaffold.harnessHome, '.env') + await expect.poll( + async () => readFile(credentialFile, 'utf8').catch(() => ''), + { timeout: 10_000 }, + ).toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') expect(await page.content()).not.toContain('sk-e2e-minimax') }, 60_000) it('applies a customized-settings field as a merge patch', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-customized')) const dialog = page.getByRole('dialog', { name: '设置' }) - await dialog.getByRole('button', { name: '编辑' }).click() + await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() await dialog.getByText('自定义设置').click() const effort = dialog.getByLabel('推理强度') await effort.waitFor({ timeout: 10_000 }) @@ -114,32 +130,32 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('confirms provider deletion before removing its settings profile', async () => { + it('confirms an identified provider deletion before removing its profile and key', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-delete')) const settingsDialog = page.getByRole('dialog', { name: '设置' }) - await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() - const deleteDialog = page.getByRole('dialog', { name: '删除模型提供方?' }) + await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click() + const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' }) await deleteDialog.waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria( page, - '[role="dialog"][aria-label="删除模型提供方?"]', + '[role="dialog"][aria-label="删除 minimax-cn?"]', scaffold.workspaceCwd, ) await compareOrRefreshGolden(DELETE_EXPECTED, snapshot, MODE) await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() expect(await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8')).toContain('minimax-cn:') - await settingsDialog.getByRole('button', { name: '删除', exact: true }).click() - await page.getByRole('dialog', { name: '删除模型提供方?' }) - .getByRole('button', { name: '删除提供方', exact: true }).click() + await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click() + await page.getByRole('dialog', { name: '删除 minimax-cn?' }) + .getByRole('button', { name: '删除 minimax-cn', exact: true }).click() await expect.poll( async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 10_000 }, ).not.toContain('minimax-cn:') expect(await readFile(join(scaffold.harnessHome, '.env'), 'utf8')) - .toContain('MINIMAX_CN_API_KEY=sk-e2e-minimax') + .not.toContain('MINIMAX_CN_API_KEY') await expect.poll( - async () => page.getByRole('dialog', { name: '删除模型提供方?' }).count(), + async () => page.getByRole('dialog', { name: '删除 minimax-cn?' }).count(), { timeout: 10_000 }, ).toBe(0) await page.keyboard.press('Escape') diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 2ff2ae3d6f..2c885817f1 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -16,8 +16,8 @@ - list: - listitem: - text: minimax-cn - - button "编辑" - - button "删除" + - button "编辑 minimax-cn": 编辑 + - button "删除 minimax-cn": 删除 - button "添加提供方": - img - text: 添加提供方 diff --git a/apps/web/tests/snapshots/models-settings/delete.expected.md b/apps/web/tests/snapshots/models-settings/delete.expected.md index afb0cb5fd2..5757ca52ca 100644 --- a/apps/web/tests/snapshots/models-settings/delete.expected.md +++ b/apps/web/tests/snapshots/models-settings/delete.expected.md @@ -1,7 +1,7 @@ -- dialog "删除模型提供方?": - - heading "删除模型提供方?" [level=2] +- dialog "删除 minimax-cn?": + - heading "删除 minimax-cn?" [level=2] - button "关闭": - img - - paragraph: 删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。 + - paragraph: 删除 minimax-cn 会移除其配置和存储的 API 密钥。 - button "取消" - - button "删除提供方" + - button "删除 minimax-cn" diff --git a/apps/web/tests/snapshots/models-settings/empty.expected.md b/apps/web/tests/snapshots/models-settings/empty.expected.md index 161b472e57..ab0a25b780 100644 --- a/apps/web/tests/snapshots/models-settings/empty.expected.md +++ b/apps/web/tests/snapshots/models-settings/empty.expected.md @@ -55,7 +55,7 @@ - option "zai-coding-cn" - text: API 密钥 - textbox "API 密钥": - - /placeholder: 输入 API 密钥 + - /placeholder: 输入 API 密钥,或留空使用环境认证 - group: 自定义设置 - button "取消" - button "保存" diff --git a/docs/config-catalog.md b/docs/config-catalog.md index abde0ea3b0..5542fae949 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -642,7 +642,10 @@ Requires: `llm` * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Trimmed literal API key; whitespace-only is absent. Prefer + * {@link apiKeyEnv} to keep secrets out of configuration files. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index 2e4cf00248..b34caf8138 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: c578ecfc9163245e8666cb6d2d327efdaccccf89 -README.zh.md: 40da5b52f681071cb5b833866270db7b37fb0957 +README.md: 6ae0dd9d43c19f2a4350386104cf328d4d4a65d3 +README.zh.md: 77e2dcfb98ac3ac12a5ecb6975487b8159178937 diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index c578ecfc91..6ae0dd9d43 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,11 +4,11 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field 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. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and a localized confirmation dialog must complete before the page submits that destructive unset. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field 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 therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. -Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. +Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling. ## Model Experience @@ -21,5 +21,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name. -- **Deleting a row leaves its stored key in `.env`** — removal unsets the settings profile but deliberately does not unset the derived credential; re-adding the provider finds the key already configured. An explicit key-removal control is deferred. +- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 40da5b52f6..77e2dcfb98 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,11 +4,11 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),而且必须先在本地化对话框中确认,页面才会提交这次破坏性的 unset。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 -每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 +每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝;settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision,因此凭据阶段失败时,重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile;两项操作都具备幂等性,部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。 ## 模型体验 @@ -21,5 +21,5 @@ ## 已知限制与暂缓事项 - **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。 -- **删除一行会把它已存储的密钥留在 `.env` 里**:删除取消设置的是 settings profile,却刻意不清除那条派生凭据;重新添加该提供方时会发现密钥已配置。显式的密钥移除控件暂缓。 +- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index b170df1fa0..54b0db3c38 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -14,7 +14,7 @@ import type { ReactNode } from 'react' import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' -import { messageOf } from './store.ts' +import { deriveKeyRef, messageOf } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' import { ProviderEditor } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -38,42 +38,53 @@ export interface ModelsSectionInjected { */ export type ModelsSectionProps = Partial -/** The editor target: an existing row or a dormant directory entry. */ -interface EditorTarget { +/** Provider identity shared by row actions and confirmation copy. */ +export interface ProviderIdentity { + /** Stable provider route id. */ provider: string + /** Human-facing provider name. */ displayName: string +} + +/** One existing row or dormant directory entry addressed by an editor action. */ +interface EditorTarget extends ProviderIdentity { settingsNs: string settingsPath: readonly string[] + /** Writable credential identified under this page's conventional reference. */ + credentialRef?: string } /** - * Remove one user-added provider profile by unsetting its path in the stored - * user section, then reload. The removal names the profile rather than - * rebuilding the section: this page only ever holds the redacted descriptor, - * so a rebuilt section would drop every literal secret stored elsewhere in - * the namespace along with the profile being removed. - * @param api - settings wire face. + * Remove one user-added provider and its page-managed credential. Credential + * removal comes first so a second-step failure leaves the provider row visible + * and the whole operation safely retryable; both unsets are idempotent. + * The settings removal names the profile rather than rebuilding its redacted + * namespace, which would drop literal secrets stored elsewhere. + * @param api - settings and credential wire faces. * @param controller - the page store to refresh. - * @param target - the provider's settings address. + * @param target - the provider's settings address and optional managed credential. * @returns the failure message, or undefined once the write and reload landed. */ export async function removeProviderProfile( - api: Pick, + api: Pick, controller: ModelsSettingsStore, - target: { settingsNs: string; settingsPath: readonly string[] }, + target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise { - let response try { - response = await api.settings.mutate({ + if (target.credentialRef !== undefined) { + const credential = await api.credentials.unset({ ref: target.credentialRef }) + if (!credential.result.ok) return credential.result.error.message + } + const response = await api.settings.mutate({ ns: target.settingsNs, ops: [{ op: 'unset', path: [...target.settingsPath] }], }) + if (!response.result.ok) return response.result.error.message } catch (error) { // The transport rejected rather than answering; the caller must be able - // to say so instead of the row silently staying put. + // to retry the idempotent operation instead of the row silently staying. return messageOf(error) } - if (!response.result.ok) return response.result.error.message await controller.load() return undefined } @@ -92,14 +103,33 @@ export function needsSetup(row: ProviderRow): boolean { } function targetOf(row: ProviderRow): EditorTarget { + const managedRef = deriveKeyRef(row.entry.provider) + const credentialRef = row.apiKeyEnv === managedRef + && row.credential?.configured === true + && row.credential.writable + ? managedRef + : undefined return { provider: row.entry.provider, displayName: row.entry.displayName, settingsNs: row.entry.settingsNs, settingsPath: row.entry.settingsPath, + ...credentialRef === undefined ? {} : { credentialRef }, } } +/** Stable visible and accessible identity for one provider target. */ +export function providerTargetLabel(target: ProviderIdentity): string { + return target.provider === target.displayName + ? target.provider + : `${target.displayName} (${target.provider})` +} + +/** Replace the one provider placeholder in localized destructive-action copy. */ +export function providerCopy(template: string, target: ProviderIdentity): string { + return template.replace('{provider}', () => providerTargetLabel(target)) +} + /** * Render the Models section content column. * @param props - slot-delivered injected dependencies. @@ -118,6 +148,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const [adding, setAdding] = useState(false) const [deleteTarget, setDeleteTarget] = useState(undefined) const [deleting, setDeleting] = useState(false) + const [deleteFailure, setDeleteFailure] = useState(undefined) const closeEditor = (changed: boolean): void => { setEditing(undefined) @@ -128,16 +159,18 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const closeDelete = (): void => { if (deleting) return setDeleteTarget(undefined) + setDeleteFailure(undefined) } const confirmDelete = (): void => { /* v8 ignore next -- the action only renders with a target and is disabled while a deletion is pending */ if (deleteTarget === undefined || deleting) return setDeleting(true) + setDeleteFailure(undefined) void removeProviderProfile(api, controller, deleteTarget) .then((failure) => { if (failure !== undefined) { - controller.fail(failure) + setDeleteFailure(failure) return } setDeleteTarget(undefined) @@ -202,6 +235,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { @@ -296,9 +331,16 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { @@ -311,11 +353,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { disabled={deleting} onClick={confirmDelete} > - {deleting ? t('deleting') : t('deleteConfirm')} + {deleteTarget === undefined + ? '' + : providerCopy(deleting ? t('deleting') : t('deleteConfirm'), deleteTarget)} )} - /> + > + {deleteFailure === undefined ? null :

{deleteFailure}

} +
) } diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 0f89f329c0..46350a145d 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -3,7 +3,9 @@ * field is a single write-only **API key** input (the page never asks for an * environment-variable name — a typed key stores through `credentials.set` * under the profile's reference, deriving `_API_KEY` when the profile - * has none, and the pi-ai profile records that derivation as `apiKeyEnv`); + * has none. The pi-ai profile records that derivation as `apiKeyEnv` only when + * a key is entered; a blank key materializes a reference-free profile for + * provider-native authentication); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for * both families, `reasoningEffort` for deepseek / `reasoning` for pi-ai, and * DeepSeek's id/name/context-window model catalog). Everything else stays @@ -131,10 +133,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const [keyState, setKeyState] = useState(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) - // The revision this card opened at. A write carrying it is refused if - // anything else — another tab, an external edit of settings.yaml — moved the - // namespace meanwhile, instead of silently overwriting that change. - const [openedAt] = useState(() => namespace.revision) + // A settings success becomes the next retry baseline immediately. If the + // following credential write fails, retry sends only the credential instead + // of replaying the already-committed settings write with a stale revision. + const [committedOriginal, setCommittedOriginal] = useState( + () => getPath(namespace.user, settingsPath), + ) + const [expectedRevision, setExpectedRevision] = useState(() => namespace.revision) const root = useMemo(() => rehydrateSchema(namespace.schema), [namespace.schema]) const node = useMemo(() => nodeAtPath(root, settingsPath), [root, settingsPath]) const fallback = getPath(namespace.value, settingsPath) @@ -176,11 +181,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { */ const applyOnce = async (): Promise => { const ns = namespace.ns - const original = getPath(namespace.user, settingsPath) - // The pi-ai profile must name the reference the key stores under, so a - // dormant add (or a legacy profile without one) records the derivation. + const normalizedKey = keyDraft.trim() + // A pi-ai profile names the conventional reference only when this page is + // about to store a key. Otherwise the provider keeps its native auth path. const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined - && stringAt(fallback, 'apiKeyEnv') === undefined + && stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0 ? setPath(draft, ['apiKeyEnv'], keyRef) : draft if (layout === 'deepseek') { @@ -194,17 +199,25 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const sectionError = validateDraft(node, next) if (sectionError !== undefined) return sectionError } - const ops = pathOps(settingsPath, original, next) + const materializesNativeProfile = layout === 'pi-ai' + && fallback === undefined + && committedOriginal === undefined + && Object.keys(next).length === 0 + const ops: SettingsPathOpView[] = materializesNativeProfile + ? [{ op: 'set', path: [...settingsPath], value: {} }] + : pathOps(settingsPath, committedOriginal, next) if (ops.length > 0) { - const response = await api.settings.mutate({ ns, ops, expectedRevision: openedAt }) + const response = await api.settings.mutate({ ns, ops, expectedRevision }) if (!response.result.ok) { return response.result.error.code === 'settings-conflict' ? t('conflict') : response.result.error.message } + setCommittedOriginal(getPath(response.result.value.user, settingsPath)) + setExpectedRevision(response.result.value.revision) } - if (keyDraft.length > 0) { - const stored = await api.credentials.set({ ref: keyRef, value: keyDraft }) + if (normalizedKey.length > 0) { + const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey }) if (!stored.result.ok) return stored.result.error.message } setKeyDraft('') @@ -263,6 +276,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const models = modelDrafts(modelsOverridden ? customModels : inheritedModels()) const defaultContextWindow = getPath(fallback, ['defaultContextWindow']) const defaultMaxTokens = getPath(fallback, ['maxTokens']) + const keyPlaceholder = keyLocked + ? t('keyEnvLocked') + : keyState?.configured === true + ? t('keyStored') + : family === 'pi-ai' ? t('keyPlaceholderNative') : t('keyPlaceholder') return ( <>
@@ -272,9 +290,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { type="password" autoComplete="off" value={keyDraft} - placeholder={keyLocked - ? t('keyEnvLocked') - : keyState?.configured === true ? t('keyStored') : t('keyPlaceholder')} + placeholder={keyPlaceholder} aria-label={t('keyInput')} disabled={disabled || keyLocked} onChange={(event) => { setKeyDraft(event.target.value) }} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index bb1254e46b..4fc76695e4 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -6,11 +6,14 @@ export const en = { title: 'Models', intro: 'Enter your API keys to use models from the following providers.', edit: 'Edit', + editProvider: 'Edit {provider}', remove: 'Delete', - deleteTitle: 'Delete model provider?', - deleteDescription: 'Deleting this model provider removes its configuration. You will not be able to use its models until you add the provider again.', - deleteConfirm: 'Delete provider', - deleting: 'Deleting provider…', + removeProvider: 'Delete {provider}', + deleteTitle: 'Delete {provider}?', + deleteDescription: 'Deleting {provider} removes its configuration. Its credential is managed elsewhere and will be kept.', + deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.', + deleteConfirm: 'Delete {provider}', + deleting: 'Deleting {provider}…', add: 'Add provider', provider: 'Provider', close: 'Close', @@ -23,6 +26,7 @@ export const en = { retry: 'Retry', keyInput: 'API key', keyPlaceholder: 'Enter your API key', + keyPlaceholderNative: 'Enter an API key, or leave blank to use environment authentication', keyStored: 'Configured — enter a new value to replace', keyEnvLocked: 'Provided by the launch environment (read-only)', customized: 'Customized settings', @@ -67,11 +71,14 @@ export const zh: typeof en = { title: '模型', intro: '填入各提供方的 API 密钥即可使用其模型。', edit: '编辑', + editProvider: '编辑 {provider}', remove: '删除', - deleteTitle: '删除模型提供方?', - deleteDescription: '删除此模型提供方会移除其配置。在重新添加前,你将无法继续使用其模型。', - deleteConfirm: '删除提供方', - deleting: '正在删除提供方…', + removeProvider: '删除 {provider}', + deleteTitle: '删除 {provider}?', + deleteDescription: '删除 {provider} 会移除其配置;凭证由其他位置管理,将会保留。', + deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。', + deleteConfirm: '删除 {provider}', + deleting: '正在删除 {provider}…', add: '添加提供方', provider: '提供方', close: '关闭', @@ -84,6 +91,7 @@ export const zh: typeof en = { retry: '重试', keyInput: 'API 密钥', keyPlaceholder: '输入 API 密钥', + keyPlaceholderNative: '输入 API 密钥,或留空使用环境认证', keyStored: '已配置——输入新值可替换', keyEnvLocked: '由启动环境提供(只读)', customized: '自定义设置', diff --git a/packages/client/ui-models/src/client/store.ts b/packages/client/ui-models/src/client/store.ts index 282f21fe75..7f1009e656 100644 --- a/packages/client/ui-models/src/client/store.ts +++ b/packages/client/ui-models/src/client/store.ts @@ -103,18 +103,6 @@ export class ModelsSettingsStore { */ constructor(private readonly api: Pick) {} - /** - * Surface a failure from an operation the page ran outside {@link load} — - * a row removal — on the same banner a load failure uses. - * @param message - the failure text to show. - */ - fail(message: string): void { - this.store.update((s) => { - s.status = 'error' - s.error = message - }) - } - /** * Refresh the whole page snapshot: directory and namespaces in parallel, * then one batched credential describe over every referenced ref. A diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index c668675be0..2842b94554 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -53,7 +53,7 @@ describe('ui-models apply', () => { expect(resolveSlotLabel(entry.options.label)).toBe('模型') const injected = (entry.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected)() expect(injected.t('nav')).toBe('模型') - expect(injected.t('deleteTitle')).toBe('删除模型提供方?') + expect(injected.t('deleteTitle')).toBe('删除 {provider}?') expect(typeof injected.controller.load).toBe('function') expect(typeof injected.useSnapshot).toBe('function') expect(injected.api).toBeDefined() @@ -80,10 +80,10 @@ describe('ui-models apply', () => { b.locale.setLocale('en') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('Models') const injected = b.slots.entries('settings.section')[0]!.inject as unknown as () => import('../src/client/ModelsSection.tsx').ModelsSectionInjected - expect(injected().t('deleteTitle')).toBe('Delete model provider?') + expect(injected().t('deleteTitle')).toBe('Delete {provider}?') b.locale.setLocale('zh') expect(resolveSlotLabel(b.slots.entries('settings.section')[0]!.options.label)).toBe('模型') - expect(injected().t('deleteTitle')).toBe('删除模型提供方?') + expect(injected().t('deleteTitle')).toBe('删除 {provider}?') }) it('locale change while the slot is undeclared stays a no-op', async () => { diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index aa9082e7dd..e28df6564d 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -5,7 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import Schema from 'schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' -import { ModelsSection, needsSetup, removeProviderProfile } from '../src/client/ModelsSection.tsx' +import { + ModelsSection, needsSetup, providerCopy, providerTargetLabel, removeProviderProfile, +} from '../src/client/ModelsSection.tsx' import type { ModelsSectionInjected, ModelsSectionProps } from '../src/client/ModelsSection.tsx' import { pathOps } from '../src/client/ProviderEditor.tsx' import { @@ -18,6 +20,8 @@ import { en } from '../src/client/locales.ts' afterEach(cleanup) const t: ModelsSectionInjected['t'] = key => en[key] +const OPENAI_TARGET = { provider: 'openai', displayName: 'openai' } +const openaiCopy = (template: string): string => providerCopy(template, OPENAI_TARGET) /** Open one row's capacity disclosure (1-based, as the labels read). */ function expandRow(position: number): void { @@ -136,11 +140,13 @@ function scriptedFace(overrides: { replace?: ReturnType mutate?: ReturnType set?: ReturnType + unset?: ReturnType } = {}) { const update = overrides.update ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) const replace = overrides.replace ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) const mutate = overrides.mutate ?? vi.fn(() => Promise.resolve(ok(wireNamespaces()[2]))) const set = overrides.set ?? vi.fn(() => Promise.resolve(ok({}))) + const unset = overrides.unset ?? vi.fn(() => Promise.resolve(ok({}))) const face = { llm: { providers: vi.fn(() => Promise.resolve(ok({ @@ -170,16 +176,16 @@ function scriptedFace(overrides: { }])), }))), set, - unset: vi.fn(() => Promise.resolve(ok({}))), + unset, }, } - return { face, update, replace, mutate, set } + return { face, update, replace, mutate, set, unset } } type WireFace = ConstructorParameters[0] async function mountSection(overrides: Parameters[0] = {}) { - const { face, update, replace, mutate, set } = scriptedFace(overrides) + const { face, update, replace, mutate, set, unset } = scriptedFace(overrides) const controller = new ModelsSettingsStore(face as unknown as WireFace) await controller.load() const injected: ModelsSectionInjected = { @@ -189,7 +195,7 @@ async function mountSection(overrides: Parameters[0] = {}) t, } const view = render() - return { view, face, update, replace, mutate, set, controller } + return { view, face, update, replace, mutate, set, unset, controller } } describe('ModelsSection', () => { @@ -254,6 +260,13 @@ describe('ModelsSection', () => { expect(deriveKeyRef('minimax-cn')).toBe('MINIMAX_CN_API_KEY') }) + it('uses one stable provider identity in action copy', () => { + const target = { provider: 'deepseek-official', displayName: 'DeepSeek' } + expect(providerTargetLabel(target)).toBe('DeepSeek (deepseek-official)') + expect(providerCopy(en.deleteTitle, target)).toBe('Delete DeepSeek (deepseek-official)?') + expect(providerTargetLabel(OPENAI_TARGET)).toBe('openai') + }) + it('names only the fields the card can see, so an unseen secret survives', () => { // `before` is the REDACTED subtree: a stored literal apiKey is in neither // side, so no op mentions it and the seam leaves it alone. @@ -268,7 +281,7 @@ describe('ModelsSection', () => { it('stores a typed key write-only from the setup card without touching settings', async () => { const { set, update, face } = await mountSection() const key = screen.getByLabelText(en.keyInput) - fireEvent.change(key, { target: { value: 'sk-live' } }) + fireEvent.change(key, { target: { value: ' sk-live ' } }) fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) }) expect(update).not.toHaveBeenCalled() @@ -777,6 +790,7 @@ describe('ModelsSection', () => { expect((urls[1] as HTMLInputElement).placeholder).toBe(en.baseUrlDefault) const keys = screen.getAllByLabelText(en.keyInput) const addKey = keys[keys.length - 1] as HTMLInputElement + expect(addKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(addKey, { target: { value: 'sk-ant' } }) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) @@ -788,6 +802,52 @@ describe('ModelsSection', () => { await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) }) }) + it('keeps pi-ai provider-native authentication when no key is entered', async () => { + const { mutate, set } = await mountSection() + fireEvent.click(screen.getByText(en.add)) + await screen.findByLabelText(en.provider) + fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-pi-ai', + ops: [{ op: 'set', path: ['providers', 'anthropic'], value: {} }], + expectedRevision: 0, + }) + expect(set).not.toHaveBeenCalled() + }) + + it('retries only the credential after settings already committed', async () => { + const committed = wireNamespaces()[2]! + const afterSettings: SettingsNamespaceView = { + ...committed, + value: { providers: { + ...(committed.value as { providers: object }).providers, + anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' }, + } }, + user: { providers: { + ...(committed.user as { providers: object }).providers, + anthropic: { apiKeyEnv: 'ANTHROPIC_API_KEY' }, + } }, + revision: 1, + } + const mutate = vi.fn(() => Promise.resolve(ok(afterSettings))) + const set = vi.fn() + .mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected')) + .mockResolvedValueOnce(ok({})) + await mountSection({ mutate, set }) + fireEvent.click(screen.getByText(en.add)) + await screen.findByLabelText(en.provider) + const keys = screen.getAllByLabelText(en.keyInput) + fireEvent.change(keys[keys.length - 1] as HTMLInputElement, { target: { value: 'sk-ant' } }) + fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + await screen.findByText('credential store unavailable') + expect(mutate).toHaveBeenCalledOnce() + fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) + await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) + expect(mutate).toHaveBeenCalledOnce() + expect(set).toHaveBeenLastCalledWith({ ref: 'ANTHROPIC_API_KEY', value: 'sk-ant' }) + }) + it('switches the add card target and degrades unknown or broken targets loudly', async () => { await mountSection() fireEvent.click(screen.getByText(en.add)) @@ -898,34 +958,37 @@ describe('ModelsSection', () => { fireEvent.click(screen.getAllByText(en.edit)[0] as HTMLElement) const keys = await screen.findAllByLabelText(en.keyInput) const editorKey = keys[keys.length - 1] as HTMLInputElement - expect(editorKey.placeholder).toBe(en.keyPlaceholder) + expect(editorKey.placeholder).toBe(en.keyPlaceholderNative) fireEvent.change(editorKey, { target: { value: 'sk-live' } }) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(set).toHaveBeenCalledTimes(1) }) }) it('requires confirmation before removing a user-added provider', async () => { - const { replace, mutate } = await mountSection() - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) - expect(dialog.textContent).toContain(en.deleteDescription) + const { replace, mutate, unset } = await mountSection() + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }) + expect(dialog.textContent).toContain(openaiCopy(en.deleteDescriptionWithCredential)) expect(document.activeElement).toBe(within(dialog).getByRole('button', { name: en.cancel })) + expect(unset).not.toHaveBeenCalled() expect(mutate).not.toHaveBeenCalled() fireEvent.click(within(dialog).getByRole('button', { name: en.cancel })) - expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() expect(mutate).not.toHaveBeenCalled() - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })) .getByRole('button', { name: en.close })) - expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() expect(mutate).not.toHaveBeenCalled() - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) - .getByRole('button', { name: en.deleteConfirm })) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + fireEvent.click(within(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })) + .getByRole('button', { name: openaiCopy(en.deleteConfirm) })) + await waitFor(() => { expect(unset).toHaveBeenCalledWith({ ref: 'OPENAI_API_KEY' }) }) await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) }) - expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() + expect(unset.mock.invocationCallOrder[0]).toBeLessThan(mutate.mock.invocationCallOrder[0] as number) + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() expect(replace).not.toHaveBeenCalled() expect(mutate.mock.calls[0]?.[0]).toEqual({ ns: 'llm-pi-ai', @@ -939,20 +1002,22 @@ describe('ModelsSection', () => { resolveRemoval = resolve })) await mountSection({ mutate }) - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - const dialog = screen.getByRole('dialog', { name: en.deleteTitle }) - const confirm = within(dialog).getByRole('button', { name: en.deleteConfirm }) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }) + const confirm = within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) }) fireEvent.click(confirm) fireEvent.click(confirm) - expect(mutate).toHaveBeenCalledOnce() + await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) expect(confirm.disabled).toBe(true) expect(within(dialog).getByRole('button', { name: en.cancel }).disabled).toBe(true) - expect(within(dialog).getByRole('button', { name: en.deleting })).toBe(confirm) + expect(within(dialog).getByRole('button', { name: openaiCopy(en.deleting) })).toBe(confirm) fireEvent.click(within(dialog).getByRole('button', { name: en.close })) - expect(screen.getByRole('dialog', { name: en.deleteTitle })).toBe(dialog) + expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog) expect(mutate).toHaveBeenCalledOnce() await act(async () => { resolveRemoval(ok(wireNamespaces()[2]!)) }) - await waitFor(() => { expect(screen.queryByRole('dialog', { name: en.deleteTitle })).toBeNull() }) + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() + }) }) it('renders the load failure with a retry control', async () => { @@ -1057,15 +1122,58 @@ describe('ModelsSection', () => { expect(controller.store.getSnapshot().rows).toBe(before) }) - it('shows a failed removal on the page banner, including a non-Error rejection', async () => { - // The whole click path: the row's Remove button, the transport rejecting - // with a non-Error value, and the store surfacing it where a load failure - // would appear — rather than the row silently staying put. - await mountSection({ mutate: vi.fn(() => Promise.reject(new Error('the host refused'))) }) - fireEvent.click(screen.getAllByText(en.remove)[0] as HTMLElement) - fireEvent.click(within(screen.getByRole('dialog', { name: en.deleteTitle })) - .getByRole('button', { name: en.deleteConfirm })) - await screen.findByText(`${en.loadFailed}: the host refused`) + it('keeps a failed identified deletion recoverable in its confirmation dialog', async () => { + const mutate = vi.fn() + .mockResolvedValueOnce(fail('the host refused')) + .mockResolvedValueOnce(ok(wireNamespaces()[2]!)) + const { unset } = await mountSection({ mutate }) + fireEvent.click(screen.getByRole('button', { name: openaiCopy(en.removeProvider) })) + const dialog = screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) }) + const confirm = within(dialog).getByRole('button', { name: openaiCopy(en.deleteConfirm) }) + fireEvent.click(confirm) + await within(dialog).findByText('the host refused') + expect(screen.getByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBe(dialog) + expect(unset).toHaveBeenCalledOnce() + expect(mutate).toHaveBeenCalledOnce() + + fireEvent.click(confirm) + await waitFor(() => { expect(unset).toHaveBeenCalledTimes(2) }) + await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(2) }) + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: openaiCopy(en.deleteTitle) })).toBeNull() + }) + }) + + it('retains credentials that are not identified as page-managed', async () => { + const { unset, mutate } = await mountSection() + const target = { provider: 'zombie', displayName: 'zombie' } + fireEvent.click(screen.getByRole('button', { name: providerCopy(en.removeProvider, target) })) + const dialog = screen.getByRole('dialog', { name: providerCopy(en.deleteTitle, target) }) + expect(dialog.textContent).toContain(providerCopy(en.deleteDescription, target)) + fireEvent.click(within(dialog).getByRole('button', { name: providerCopy(en.deleteConfirm, target) })) + await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + expect(unset).not.toHaveBeenCalled() + expect(mutate.mock.calls[0]?.[0]).toEqual({ + ns: 'llm-pi-ai', + ops: [{ op: 'unset', path: ['providers', 'zombie'] }], + }) + }) + + it('does not remove provider settings when its managed credential removal is refused', async () => { + const { face, controller, mutate } = await mountSection({ + unset: vi.fn(() => Promise.resolve(fail('credential is read-only', 'credential-rejected'))), + }) + const failure = await removeProviderProfile( + face as unknown as Parameters[0], + controller, + { + settingsNs: 'llm-pi-ai', + settingsPath: ['providers', 'openai'], + credentialRef: 'OPENAI_API_KEY', + }, + ) + expect(failure).toBe('credential is read-only') + expect(mutate).not.toHaveBeenCalled() }) it('reports a transport rejection instead of failing the removal silently', async () => { diff --git a/packages/llm/llm-deepseek/README.i18n.yaml b/packages/llm/llm-deepseek/README.i18n.yaml index 3eb54a7a9f..c0e02b2a57 100644 --- a/packages/llm/llm-deepseek/README.i18n.yaml +++ b/packages/llm/llm-deepseek/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-deepseek/README.md -README.md: 0cd265cadb2b2a619613761062ab2cef209bec83 -README.zh.md: 1883b054277adfd6c3d02b2a76ead9b3f8b0138f +README.md: b583ecadf23ec4d089bfc9473dc1165c3e70ae9a +README.zh.md: 42d38e913b98b9ed2cf1781fdc6f716a0050c905 diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 0cd265cadb..b583ecadf2 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -53,7 +53,7 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und Connection facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget all take effect on the next request, while an in-flight stream keeps the facts it started with. Two optional seams feed that thunk: - **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`, so a `llm-deepseek:` section in the user settings document overrides any field without a restart. Without a mounted settings service the entry config alone drives the adapter, unchanged. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good facts and logs the failure; the entry config itself still fails plugin load. -- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. +- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a trimmed, non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Whitespace-only literals are absent rather than Authorization values. Because credential facts travel with the connection facts, a settings snapshot the resolver rejects contributes neither its endpoint nor its key: the whole previous generation keeps serving. A request with no key anywhere fails with `MISSING_CREDENTIAL` naming every configuration entry point, while the route stays registered and the catalog stays browsable — first-run onboarding is "browse models, store the key, prompt again", with no restart between. The one registration-captured fact is the retry policy: when its resolved value changes, the plugin re-registers the route in place (same adapter instance, one synchronous section), so `ctx.llm.providerRetryPolicy('deepseek-official')` always reports the current policy. diff --git a/packages/llm/llm-deepseek/README.zh.md b/packages/llm/llm-deepseek/README.zh.md index 1883b05427..42d38e913b 100644 --- a/packages/llm/llm-deepseek/README.zh.md +++ b/packages/llm/llm-deepseek/README.zh.md @@ -53,7 +53,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器: 连接事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次请求生效,进行中的流则保持其起始事实。两个可选 seam 供给该 thunk: - **`ctx.settings`**——插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`,因此用户设置文档中的 `llm-deepseek:` 分节可以免重启覆盖任何字段。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用事实并记录失败;entry 配置本身仍会使插件加载失败。 -- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 +- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。 diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index cd2bb9a24e..d55cea3ded 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -58,7 +58,10 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [ * reasoning effort resolves to `high`. */ export interface Config { - /** Literal API key; prefer {@link apiKeyEnv} so no secret enters configuration files. */ + /** + * Trimmed literal API key; whitespace-only is absent. Prefer + * {@link apiKeyEnv} to keep secrets out of configuration files. + */ apiKey?: string /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */ apiKeyEnv?: string @@ -153,6 +156,7 @@ function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): Dee * @returns validated connection facts plus the credential reference. */ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { + const apiKey = config.apiKey?.trim() if (config.thinking === 'disabled' && config.reasoningEffort !== undefined && config.reasoningEffort !== 'off') { @@ -175,7 +179,7 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions { ) } return { - ...config.apiKey !== undefined && config.apiKey.length > 0 ? { apiKey: config.apiKey } : {}, + ...apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {}, apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV), baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL, defaults: { diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 9d104ace08..7146913bed 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -699,6 +699,13 @@ describe('plugin registration and config', () => { }) }) + it('normalizes a literal API key and treats whitespace as absent', () => { + expect(resolveAdapterOptions({ apiKey: ' key ' }).apiKey).toBe('key') + const whitespace = resolveAdapterOptions({ apiKey: ' \t ', apiKeyEnv: 'CUSTOM_API_KEY' }) + expect(whitespace.apiKey).toBeUndefined() + expect(whitespace.apiKeyEnv).toBe('CUSTOM_API_KEY') + }) + it('uses the default model catalog when apply is called directly', async () => { const ctx = new Context() await ctx.plugin(LlmService) From f330b6ae796e22df398fea7d9ccdeb9b155d17b8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 12:51:18 +0800 Subject: [PATCH 2/5] test(web): refresh targeted provider action golden --- .../snapshots/onboarding-deepseek-config/models.expected.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index f0177144c6..3eaef94eef 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -16,7 +16,7 @@ - list: - listitem: - text: DeepSeek - - button "编辑" + - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 - textbox "API 密钥": - /placeholder: 已配置——输入新值可替换 From 099b903ac6ccd124acb653db4d528dd51f9b7c00 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 13:15:09 +0800 Subject: [PATCH 3/5] fix(web): preserve provider credential retry checkpoint --- apps/web/tests/models-settings.e2e.ts | 26 ++++++++++++++++--- .../models-settings/native-delete.expected.md | 7 +++++ .../ui-models/src/client/ProviderEditor.tsx | 7 ++--- .../client/ui-models/src/client/locales.ts | 4 +-- .../ui-models/tests/components.spec.tsx | 11 ++++++-- 5 files changed, 44 insertions(+), 11 deletions(-) create mode 100644 apps/web/tests/snapshots/models-settings/native-delete.expected.md diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 36892f2071..9078e53ff6 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -9,9 +9,9 @@ // settings/credentials/llm-domain traffic, so there is no fixture and a // stray stream would fail loud on the open seam. The provider under test is // minimax-cn so a developer's real ANTHROPIC/OPENAI environment keys can -// never shadow the derived reference. Removing that row is guarded by the -// localized, identified provider-confirmation dialog before the credential -// and settings unsets reach the wire. +// never shadow the derived reference. The deletion dialog distinguishes a +// reference-free profile from a page-managed key before the credential and +// settings unsets reach the wire. import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' @@ -27,6 +27,7 @@ import { ZH_BROWSER_LOCALE, saveFailureShot } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/models-settings', import.meta.url)) const EMPTY_EXPECTED = join(SNAPSHOT_DIR, 'empty.expected.md') const CONFIGURED_EXPECTED = join(SNAPSHOT_DIR, 'configured.expected.md') +const NATIVE_DELETE_EXPECTED = join(SNAPSHOT_DIR, 'native-delete.expected.md') const DELETE_EXPECTED = join(SNAPSHOT_DIR, 'delete.expected.md') const MODE = webSnapshotMode() @@ -88,6 +89,21 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(document).not.toContain('MINIMAX_CN_API_KEY') }, 60_000) + it('describes reference-free deletion without claiming a credential exists', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-models-native-delete')) + const settingsDialog = page.getByRole('dialog', { name: '设置' }) + await settingsDialog.getByRole('button', { name: '删除 minimax-cn', exact: true }).click() + const deleteDialog = page.getByRole('dialog', { name: '删除 minimax-cn?' }) + await deleteDialog.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria( + page, + '[role="dialog"][aria-label="删除 minimax-cn?"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(NATIVE_DELETE_EXPECTED, snapshot, MODE) + await deleteDialog.getByRole('button', { name: '取消', exact: true }).click() + }, 60_000) + it('stores the key under the derived reference and keeps the route live', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) @@ -163,6 +179,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['configured.expected.md', 'delete.expected.md', 'empty.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, [ + 'configured.expected.md', 'delete.expected.md', 'empty.expected.md', 'native-delete.expected.md', + ]) }) }) diff --git a/apps/web/tests/snapshots/models-settings/native-delete.expected.md b/apps/web/tests/snapshots/models-settings/native-delete.expected.md new file mode 100644 index 0000000000..6ff480db12 --- /dev/null +++ b/apps/web/tests/snapshots/models-settings/native-delete.expected.md @@ -0,0 +1,7 @@ +- dialog "删除 minimax-cn?": + - heading "删除 minimax-cn?" [level=2] + - button "关闭": + - img + - paragraph: 删除 minimax-cn 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。 + - button "取消" + - button "删除 minimax-cn" diff --git a/packages/client/ui-models/src/client/ProviderEditor.tsx b/packages/client/ui-models/src/client/ProviderEditor.tsx index 46350a145d..30f5c376e0 100644 --- a/packages/client/ui-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-models/src/client/ProviderEditor.tsx @@ -133,9 +133,9 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const [keyState, setKeyState] = useState(undefined) const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) - // A settings success becomes the next retry baseline immediately. If the - // following credential write fails, retry sends only the credential instead - // of replaying the already-committed settings write with a stale revision. + // A settings success advances both retry baselines immediately. Keeping the + // derived fields in the draft prevents a pushed namespace refresh from + // turning them into deletions when the following credential write is retried. const [committedOriginal, setCommittedOriginal] = useState( () => getPath(namespace.user, settingsPath), ) @@ -215,6 +215,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { } setCommittedOriginal(getPath(response.result.value.user, settingsPath)) setExpectedRevision(response.result.value.revision) + setDraft(next) } if (normalizedKey.length > 0) { const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey }) diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 4fc76695e4..d85a3dd964 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -10,7 +10,7 @@ export const en = { remove: 'Delete', removeProvider: 'Delete {provider}', deleteTitle: 'Delete {provider}?', - deleteDescription: 'Deleting {provider} removes its configuration. Its credential is managed elsewhere and will be kept.', + deleteDescription: 'Deleting {provider} removes its configuration. Any credential it uses is managed elsewhere and will be kept.', deleteDescriptionWithCredential: 'Deleting {provider} removes its configuration and stored API key.', deleteConfirm: 'Delete {provider}', deleting: 'Deleting {provider}…', @@ -75,7 +75,7 @@ export const zh: typeof en = { remove: '删除', removeProvider: '删除 {provider}', deleteTitle: '删除 {provider}?', - deleteDescription: '删除 {provider} 会移除其配置;凭证由其他位置管理,将会保留。', + deleteDescription: '删除 {provider} 会移除其配置;其使用的凭证(如有)由其他位置管理,将会保留。', deleteDescriptionWithCredential: '删除 {provider} 会移除其配置和存储的 API 密钥。', deleteConfirm: '删除 {provider}', deleting: '正在删除 {provider}…', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index e28df6564d..29600642a2 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -816,7 +816,7 @@ describe('ModelsSection', () => { expect(set).not.toHaveBeenCalled() }) - it('retries only the credential after settings already committed', async () => { + it('retries only the credential after refreshed settings already committed', async () => { const committed = wireNamespaces()[2]! const afterSettings: SettingsNamespaceView = { ...committed, @@ -834,7 +834,7 @@ describe('ModelsSection', () => { const set = vi.fn() .mockResolvedValueOnce(fail('credential store unavailable', 'credential-rejected')) .mockResolvedValueOnce(ok({})) - await mountSection({ mutate, set }) + const { face, controller } = await mountSection({ mutate, set }) fireEvent.click(screen.getByText(en.add)) await screen.findByLabelText(en.provider) const keys = screen.getAllByLabelText(en.keyInput) @@ -842,6 +842,13 @@ describe('ModelsSection', () => { fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await screen.findByText('credential store unavailable') expect(mutate).toHaveBeenCalledOnce() + face.settings.describe.mockResolvedValue(ok({ + writable: true, + hasDocument: false, + namespaces: wireNamespaces().map(namespace => namespace.ns === 'llm-pi-ai' ? afterSettings : namespace), + })) + await act(async () => { await controller.load() }) + expect(controller.store.getSnapshot().namespaces.get('llm-pi-ai')?.revision).toBe(1) fireEvent.click(screen.getAllByText(en.apply)[1] as HTMLElement) await waitFor(() => { expect(set).toHaveBeenCalledTimes(2) }) expect(mutate).toHaveBeenCalledOnce() From f2050bfd1e6c3b655c041fb2a07fa92f25f62749 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 16:44:04 +0800 Subject: [PATCH 4/5] fix(web): surface provider credential status --- ...06-provider-credential-lifecycle.i18n.yaml | 4 +- ...026-08-06-provider-credential-lifecycle.md | 4 +- ...-08-06-provider-credential-lifecycle.zh.md | 4 +- apps/web/tests/models-settings.e2e.ts | 15 +++- .../models-settings/configured.expected.md | 2 + .../models.expected.md | 1 + packages/client/ui-models/README.i18n.yaml | 4 +- packages/client/ui-models/README.md | 2 +- packages/client/ui-models/README.zh.md | 2 +- .../src/client/ModelsSection.module.css | 31 ++++++++ .../ui-models/src/client/ModelsSection.tsx | 70 +++++++++++++++---- .../client/ui-models/src/client/locales.ts | 6 ++ .../ui-models/tests/components.spec.tsx | 33 +++++++++ 13 files changed, 153 insertions(+), 25 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml index 11ba2e0744..9f16a183b9 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.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-08-06-provider-credential-lifecycle.md -2026-08-06-provider-credential-lifecycle.md: 6965d573af6989dffd7b6066fd8b3e50872a6a25 -2026-08-06-provider-credential-lifecycle.zh.md: de6f76d0725e954e27ec99062832fe40c36fcfe9 +2026-08-06-provider-credential-lifecycle.md: ce45207e7ac7224f44e34945e36ba85db0971f09 +2026-08-06-provider-credential-lifecycle.zh.md: c476417517b8ed72036344a13720a8ba378775e6 diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md index 6965d573af..ce45207e7a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md +++ b/.agents/notes/implemented/bug-fix/2026-08-06-provider-credential-lifecycle.md @@ -12,7 +12,7 @@ The Models editor spans independent settings and credential RPC domains. It prev Provider save remains a two-stage settings-then-credentials operation over the existing wire domains, but the card treats the successful settings response as a commit checkpoint. It replaces its comparison subtree and expected revision with the returned redacted descriptor before attempting `credentials.set`; if that second stage fails, the draft key and card stay visible, and retry produces no settings ops and repeats only the credential write. Genuine concurrent changes before the first settings commit still fail with `settings-conflict`. Typed keys are trimmed at the UI and direct DeepSeek resolver boundaries, and pi-ai records a derived reference only when the normalized key is non-empty; saving a blank key materializes an empty, reference-free profile for provider-native discovery. -Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. +Deletion removes a credential only when the joined row identifies the exact `_API_KEY` reference derived by this page and reports it configured and writable. It unsets that credential before the user-layer profile so a settings-stage failure leaves the row and its frozen target visible for retry; both unsets are idempotent. Custom references, environment credentials, missing credentials, and targets the join cannot identify are retained. The row's accessible Edit/Delete names and the destructive dialog title, description, and final action all use the same stable `Display Name (route-id)` identity, collapsing to the route id when both strings match. The dialog states whether the stored key will be removed and owns operation failures instead of replacing the whole page with a load-error banner. Rows expose API-key state only from the value-free join: a confirmed literal or referenced credential is a green solid dot, a confirmed missing named reference is a red solid dot, and reference-free provider-native authentication or unavailable credential enrichment has no dot. Each dot has accessible copy and a tooltip, while successful Apply uses the same provider identity in a local status message and never echoes secret material. ## Alternatives considered @@ -24,4 +24,4 @@ Deletion removes a credential only when the joined row identifies the exact `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。 +只有当联接所得的行识别出该页面派生的精确 `_API_KEY` 引用,并将其报告为已配置且可写时,删除操作才会清除该凭据。它会先取消设置该凭据,再取消设置用户层 profile;如果 settings 阶段失败,该行及其已冻结的目标仍可见,便于重试。两项 unset 都具备幂等性。自定义引用、环境凭据、缺失的凭据,以及联接无法识别目标的凭据均会保留。行的无障碍 Edit/Delete 名称以及破坏性对话框的标题、说明和最终操作都使用同一个稳定的 `Display Name (route-id)` 标识;当两个字符串相同时,标识会简化为路由 id。对话框会说明是否一并删除已存密钥,并在自身内显示操作失败,而不是用加载错误横幅替换整个页面。行只根据不含值的联接结果展示 API 密钥状态:确认已配置的字面密钥或引用凭据显示为绿色实心点,确认缺失的具名引用显示为红色实心点,无引用的提供方原生认证或无法取得凭据补充信息时则不显示状态点。每个状态点都有无障碍文案和工具提示;「应用」成功后的本地状态消息会使用同一个提供方标识,且绝不回显任何机密内容。 ## 曾考虑的替代方案 @@ -24,4 +24,4 @@ Models 编辑器横跨互相独立的 settings 与凭据 RPC 领域。之前它 ## 后果 -Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 +Models 页可以从任一第二阶段失败中恢复,无需重新加载,也不会泄露机密或产生虚假的并发冲突;空密钥的 pi-ai profile 会保留 Bedrock、Vertex 与其他提供方原生认证。已确认的状态清晰可见,同时不会把路由存活状态、原生认证或凭据查询失败误报为错误;即使该行继续显示绿色,密钥替换成功也仍然可观察。删除由页面管理的提供方不再遗留可重用的本地密钥,而存在歧义的凭据会有意保留,交由手动管理。保存与删除在跨持久存储时仍非原子操作:进程可能在两个阶段之间崩溃,但它们的顺序与幂等性会留下可观察、可重试的状态。组件测试固定了部分成功后的重试、空密钥原生认证、标准化字面值、状态可见性、目标标识、清理所有权,以及凭据/settings 拒绝顺序;无密钥的浏览器场景固定了双语无障碍文案,并验证确认删除会同时清除 `settings.yaml` profile 与 `.env` 凭据。此决策细化了 [web 配置平面 note](../architecture/2026-07-30-web-config-plane.md) 中记录的 Models 应用语义。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 9078e53ff6..c688e12e6a 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -73,7 +73,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(options).toContain('anthropic') expect(options).toContain('minimax-cn') await pick.selectOption('minimax-cn') - await dialog.getByLabel('API 密钥').waitFor({ timeout: 10_000 }) + await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).waitFor({ timeout: 10_000 }) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE) }, 60_000) @@ -84,6 +84,9 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByRole('button', { name: '保存', exact: true }).click() const row = dialog.getByText('minimax-cn', { exact: true }).first() await row.waitFor({ timeout: 10_000 }) + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) + expect(await dialog.getByRole('img', { name: 'API 密钥已配置' }).count()).toBe(0) + expect(await dialog.getByRole('img', { name: 'API 密钥缺失' }).count()).toBe(0) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn: {}') expect(document).not.toContain('MINIMAX_CN_API_KEY') @@ -108,12 +111,17 @@ describe('web e2e: Models settings page configures a dormant provider', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-add')) const dialog = page.getByRole('dialog', { name: '设置' }) await dialog.getByRole('button', { name: '编辑 minimax-cn' }).click() - await dialog.getByLabel('API 密钥').fill('sk-e2e-minimax') + await dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).fill('sk-e2e-minimax') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The profile lands in settings.yaml with only the derived reference, the // key value lands in the harness home's .env, the dormant route // registers, and the topology frame invalidates the page into the row. - await expect.poll(async () => dialog.getByLabel('API 密钥').count(), { timeout: 10_000 }).toBe(0) + await expect.poll( + async () => dialog.getByRole('textbox', { name: 'API 密钥', exact: true }).count(), + { timeout: 10_000 }, + ).toBe(0) + await dialog.getByRole('img', { name: 'API 密钥已配置' }).waitFor({ timeout: 10_000 }) + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('minimax-cn:') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') @@ -138,6 +146,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { // The editor closes back to the row; the fold's write merged into the // stored profile beside the reference. await expect.poll(async () => dialog.getByLabel('推理强度').count(), { timeout: 10_000 }).toBe(0) + await dialog.getByText('已保存 minimax-cn。', { exact: true }).waitFor({ timeout: 10_000 }) const document = await readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8') expect(document).toContain('reasoning: high') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') diff --git a/apps/web/tests/snapshots/models-settings/configured.expected.md b/apps/web/tests/snapshots/models-settings/configured.expected.md index 2c885817f1..4f861cd8a8 100644 --- a/apps/web/tests/snapshots/models-settings/configured.expected.md +++ b/apps/web/tests/snapshots/models-settings/configured.expected.md @@ -13,9 +13,11 @@ - text: 关闭 - heading "模型" [level=2] - paragraph: 填入各提供方的 API 密钥即可使用其模型。 + - status: 已保存 minimax-cn。 - list: - listitem: - text: minimax-cn + - img "API 密钥已配置" - button "编辑 minimax-cn": 编辑 - button "删除 minimax-cn": 删除 - button "添加提供方": diff --git a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md index 3eaef94eef..1438c94822 100644 --- a/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/snapshots/onboarding-deepseek-config/models.expected.md @@ -16,6 +16,7 @@ - list: - listitem: - text: DeepSeek + - img "API 密钥已配置" - button "编辑 DeepSeek (deepseek-official)": 编辑 - text: DeepSeek deepseek-official API 密钥 - textbox "API 密钥": diff --git a/packages/client/ui-models/README.i18n.yaml b/packages/client/ui-models/README.i18n.yaml index b34caf8138..4b622ca023 100644 --- a/packages/client/ui-models/README.i18n.yaml +++ b/packages/client/ui-models/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-models/README.md -README.md: 6ae0dd9d43c19f2a4350386104cf328d4d4a65d3 -README.zh.md: 77e2dcfb98ac3ac12a5ecb6975487b8159178937 +README.md: fdbd758e81e9631bf607797bfe2c2385c2b89ac6 +README.zh.md: 44498d728b4e292c717fa59c466a261b69cfa24b diff --git a/packages/client/ui-models/README.md b/packages/client/ui-models/README.md index 6ae0dd9d43..fdbd758e81 100644 --- a/packages/client/ui-models/README.md +++ b/packages/client/ui-models/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status. -Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The editor is a hand-written card per adapter family: the primary field 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 therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. +Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. A row labels API-key state with a green solid dot only when a literal key or referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field 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 therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), `reasoningEffort` (deepseek) or `reasoning` (pi-ai), and the direct DeepSeek adapter's advisory model catalog. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface. diff --git a/packages/client/ui-models/README.zh.md b/packages/client/ui-models/README.zh.md index 77e2dcfb98..44498d728b 100644 --- a/packages/client/ui-models/README.zh.md +++ b/packages/client/ui-models/README.zh.md @@ -4,7 +4,7 @@ 模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。 -行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 +行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。只有确认字面密钥或引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),另有 `reasoningEffort`(deepseek)或 `reasoning`(pi-ai),以及直接 DeepSeek 适配器的建议性模型目录。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。 前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置,或凭据引用已配置,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。 diff --git a/packages/client/ui-models/src/client/ModelsSection.module.css b/packages/client/ui-models/src/client/ModelsSection.module.css index 6b87dbefe3..0615a9ec25 100644 --- a/packages/client/ui-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-models/src/client/ModelsSection.module.css @@ -38,6 +38,13 @@ color: var(--dsw-alias-state-warn-label); } +.savedNotice { + margin: 0; + font-size: 12px; + line-height: 18px; + color: var(--dsw-alias-state-success-primary); +} + .rows { list-style: none; /* Extra air between the title/intro block and the first provider card. */ @@ -65,6 +72,13 @@ gap: 10px; } +.rowIdentity { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .rowName { font-size: 14px; line-height: 22px; @@ -72,6 +86,23 @@ color: var(--dsw-alias-label-primary); } +.credentialDot { + box-sizing: border-box; + display: inline-block; + flex: none; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.credentialDotConfigured { + background: var(--dsw-alias-state-success-primary); +} + +.credentialDotMissing { + background: var(--dsw-alias-state-error-primary); +} + .rowActions { display: inline-flex; align-items: center; diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index 54b0db3c38..3abdea3a61 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -1,10 +1,11 @@ /** * Models settings section: the provider rows joined from the configurable * directory, settings namespaces, and credential states, with one editor - * card at a time. A whole-section provider without a configured key (the - * unconfigured DeepSeek posture) renders as its open setup card instead of a - * row; the add flow is a card carrying the dormant-provider select. Every - * mutation writes through the wire, while a provider removal first requires + * card at a time. Rows expose only confirmed API-key state through accessible + * solid configured or missing dots. A whole-section provider without a + * configured key (the unconfigured DeepSeek posture) renders as its open setup + * card instead of a row; the add flow is a card carrying the dormant-provider + * select. Every mutation writes through the wire, while a provider removal first requires * confirmation; the page re-renders from pushed invalidations or the * post-apply reload. */ @@ -149,11 +150,15 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const [deleteTarget, setDeleteTarget] = useState(undefined) const [deleting, setDeleting] = useState(false) const [deleteFailure, setDeleteFailure] = useState(undefined) + const [savedTarget, setSavedTarget] = useState(undefined) - const closeEditor = (changed: boolean): void => { + const closeEditor = (changed: boolean, target: ProviderIdentity): void => { setEditing(undefined) setAdding(false) - if (changed) void controller.load() + if (changed) { + setSavedTarget(target) + void controller.load() + } } const closeDelete = (): void => { @@ -202,6 +207,13 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {

{t('title')}

{t('intro')}

{!state.writable && state.status === 'ready' ?

{t('readOnly')}

: null} + {savedTarget === undefined + ? null + : ( +

+ {providerCopy(t('savedProvider'), savedTarget)} +

+ )}
    {configured.map((row) => { const target = targetOf(row) @@ -221,22 +233,51 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api={api} t={t} readOnly={!state.writable} - onClose={closeEditor} + onClose={(changed) => { closeEditor(changed, target) }} /> ) } const open = !adding && editing?.provider === row.entry.provider + const credentialConfigured = row.literalApiKeyConfigured || row.credential?.configured === true + const credentialMissing = !credentialConfigured + && row.apiKeyEnv !== undefined + && row.credential?.configured === false return (
  • - {row.entry.displayName} + + {row.entry.displayName} + {credentialConfigured + ? ( + + ) + : credentialMissing + ? ( + + ) + : null} + @@ -247,7 +288,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { className={styles['dangerButton']} aria-label={providerCopy(t('removeProvider'), target)} disabled={!state.writable} - onClick={() => { setDeleteFailure(undefined); setDeleteTarget(target) }} + onClick={() => { + setSavedTarget(undefined) + setDeleteFailure(undefined) + setDeleteTarget(target) + }} > {t('remove')} @@ -265,7 +310,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api={api} t={t} readOnly={!state.writable} - onClose={closeEditor} + onClose={(changed) => { closeEditor(changed, target) }} /> ) : null} @@ -305,7 +350,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { api={api} t={t} readOnly={!state.writable} - onClose={closeEditor} + onClose={(changed) => { closeEditor(changed, addTarget) }} />
    ) @@ -318,6 +363,7 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { const first = addable[0] /* v8 ignore next -- the button is disabled while nothing is addable */ if (first === undefined) return + setSavedTarget(undefined) setAdding(true) setEditing(targetOf(first)) }} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index d85a3dd964..6faa8ab2d1 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -20,6 +20,9 @@ export const en = { cancel: 'Cancel', apply: 'Apply', applying: 'Applying…', + savedProvider: 'Saved {provider}.', + credentialConfigured: 'API key configured', + credentialMissing: 'API key missing', readOnly: 'The settings document is read-only in this deployment.', loadFailed: 'Loading the provider directory failed', conflict: 'Someone else changed these settings while this card was open. Close it and reopen to edit the current values.', @@ -85,6 +88,9 @@ export const zh: typeof en = { cancel: '取消', apply: '保存', applying: '保存中…', + savedProvider: '已保存 {provider}。', + credentialConfigured: 'API 密钥已配置', + credentialMissing: 'API 密钥缺失', readOnly: '当前部署的设置文档为只读。', loadFailed: '加载提供方目录失败', conflict: '这张卡片打开期间,这些设置已被其他地方改动。请关闭后重新打开,在当前值上编辑。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 29600642a2..4d6ca68670 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -213,9 +213,36 @@ describe('ModelsSection', () => { expect(screen.getByText('openai')).toBeTruthy() expect(screen.queryByText('Active')).toBeNull() expect(screen.queryByText('Inactive')).toBeNull() + const configured = screen.getByRole('img', { name: en.credentialConfigured }) + expect(configured.getAttribute('title')).toBe(en.credentialConfigured) + expect(configured.className).toContain('credentialDotConfigured') + expect(configured.closest('li')?.textContent).toContain('openai') + expect(screen.queryByRole('img', { name: en.credentialMissing })).toBeNull() expect(screen.getByText(en.add)).toBeTruthy() }) + it('marks only a confirmed missing reference and leaves native or unavailable state unmarked', async () => { + const { face } = scriptedFace() + face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ + credentials: Object.fromEntries(payload.refs.map(ref => [ref, { configured: false, writable: true }])), + }))) + const controller = new ModelsSettingsStore(face as unknown as WireFace) + await controller.load() + render() + + const missing = screen.getByRole('img', { name: en.credentialMissing }) + expect(missing.getAttribute('title')).toBe(en.credentialMissing) + expect(missing.className).toContain('credentialDotMissing') + expect(missing.closest('li')?.textContent).toContain('openai') + expect(screen.queryByRole('img', { name: en.credentialConfigured })).toBeNull() + expect(screen.getByText('zombie').closest('li')?.querySelector('[role="img"]')).toBeNull() + }) + it('turns the setup card into a row once the credential reports configured', async () => { const { face } = await mountSection() face.credentials.describe.mockImplementation((payload: { refs: string[] }) => Promise.resolve(ok({ @@ -286,6 +313,11 @@ describe('ModelsSection', () => { await waitFor(() => { expect(set).toHaveBeenCalledWith({ ref: 'DEEPSEEK_API_KEY', value: 'sk-live' }) }) expect(update).not.toHaveBeenCalled() await waitFor(() => { expect(face.settings.describe.mock.calls.length).toBeGreaterThan(1) }) + expect((await screen.findByRole('status')).textContent).toBe( + providerCopy(en.savedProvider, { provider: 'deepseek-official', displayName: 'DeepSeek' }), + ) + fireEvent.click(screen.getByText(en.add)) + expect(screen.queryByRole('status')).toBeNull() }) it('applies customized deepseek fields as path ops', async () => { @@ -943,6 +975,7 @@ describe('ModelsSection', () => { fireEvent.change(key, { target: { value: 'sk-live' } }) fireEvent.click(screen.getByText(en.apply)) await screen.findByText(/shadowed by the read-only environment/) + expect(screen.queryByRole('status')).toBeNull() }) it('locks the key input when the launch environment provides the credential', async () => { From e43e4f187e2126806b21063869769d8bafdf7af0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 6 Aug 2026 17:31:25 +0800 Subject: [PATCH 5/5] fix(web): satisfy provider model gates --- .../ui-models/src/client/ModelsSection.tsx | 60 ++++++++++++------- .../ui-models/tests/provider-form.spec.tsx | 18 +++++- 2 files changed, 54 insertions(+), 24 deletions(-) diff --git a/packages/client/ui-models/src/client/ModelsSection.tsx b/packages/client/ui-models/src/client/ModelsSection.tsx index b5a09021ff..b5a2801bf5 100644 --- a/packages/client/ui-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-models/src/client/ModelsSection.tsx @@ -18,7 +18,7 @@ import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react' import { CustomProviderCard } from './CustomProviderCard.tsx' import { deriveKeyRef, messageOf, protocolChoices } from './store.ts' import type { ModelsSettingsState, ModelsSettingsStore, ProviderRow } from './store.ts' -import { ProviderEditor } from './ProviderEditor.tsx' +import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -56,6 +56,26 @@ interface EditorTarget extends ProviderIdentity { credentialRef?: string } +/** Values that vary around the shared provider-editor rendering. */ +interface ProviderEditorRenderProps extends Pick< + ProviderEditorProps, + 'namespace' | 'api' | 't' | 'readOnly' | 'onClose' +> { + target: EditorTarget +} + +/** Render an editor for either the setup posture or an expanded provider row. */ +function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): ReactNode { + return ( + + ) +} + /** * Remove one user-added provider and its page-managed credential. Credential * removal comes first so a second-step failure leaves the provider row visible @@ -232,16 +252,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode { // setup card IS its presence on the page. return (
  • - { closeEditor(changed, target) }} - /> + {renderProviderEditor({ + target, + namespace, + api, + t, + readOnly: !state.writable, + onClose: (changed) => { closeEditor(changed, target) }, + })}
  • ) } @@ -312,18 +330,14 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
{open - ? ( - { closeEditor(changed, target) }} - /> - ) + ? renderProviderEditor({ + target, + namespace, + api, + t, + readOnly: !state.writable, + onClose: (changed) => { closeEditor(changed, target) }, + }) : null} ) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 99e85b0d10..367be642d0 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -142,7 +142,7 @@ async function mountSection(options: Parameters[0] = {}) { t, } render() - return scripted + return { ...scripted, controller } } /** Open the editor of one configured row and expand its customized fold. */ @@ -862,4 +862,20 @@ describe('hand-declared providers', () => { await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() }) expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy() }) + + it('reloads the section after creating a hand-declared provider', async () => { + const { controller, mutate } = await mountSection() + const load = vi.spyOn(controller, 'load') + + fireEvent.click(screen.getByRole('button', { name: en.customAdd })) + fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) + fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) + fireEvent.click(screen.getByRole('button', { name: en.addModel })) + fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'm' } }) + fireEvent.click(screen.getByText(en.create)) + + await waitFor(() => { expect(mutate).toHaveBeenCalledOnce() }) + await waitFor(() => { expect(load).toHaveBeenCalledOnce() }) + expect(screen.queryByText(en.customTitle)).toBeNull() + }) })