Explorar o código

Merge pull request #1827 from deepseek-harness/worktree/charming-swartz-83bf33

fix(llm,web): validate API key format before it reaches an HTTP header
Yichen Jiang hai 1 mes
pai
achega
23a4c0a51e
Modificáronse 41 ficheiros con 1019 adicións e 55 borrados
  1. 6 0
      .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.i18n.yaml
  2. 107 0
      .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md
  3. 107 0
      .agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md
  4. 19 0
      apps/web/tests/models-settings.e2e.ts
  5. 11 4
      docs/config-catalog.md
  6. 2 2
      docs/cordis-catalog/events.md
  7. 1 1
      docs/cordis-catalog/services.md
  8. 2 2
      docs/event-producer-consumer.md
  9. 40 0
      examples/headless-agent/tests/headless.snapshot.ts
  10. 12 0
      examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl
  11. 2 2
      packages/client/ui-models/README.i18n.yaml
  12. 0 0
      packages/client/ui-models/README.md
  13. 0 0
      packages/client/ui-models/README.zh.md
  14. 21 3
      packages/client/ui-models/src/client/CustomProviderCard.tsx
  15. 11 2
      packages/client/ui-models/src/client/ModelListEditor.tsx
  16. 22 9
      packages/client/ui-models/src/client/ProviderEditor.tsx
  17. 58 0
      packages/client/ui-models/src/client/apiKey.ts
  18. 6 0
      packages/client/ui-models/src/client/locales.ts
  19. 52 0
      packages/client/ui-models/tests/components.spec.tsx
  20. 164 0
      packages/client/ui-models/tests/provider-form.spec.tsx
  21. 2 2
      packages/llm/llm-deepseek/README.i18n.yaml
  22. 1 1
      packages/llm/llm-deepseek/README.md
  23. 1 1
      packages/llm/llm-deepseek/README.zh.md
  24. 26 7
      packages/llm/llm-deepseek/src/index.ts
  25. 35 0
      packages/llm/llm-deepseek/tests/adapter.spec.ts
  26. 20 1
      packages/llm/llm-deepseek/tests/dynamic-config.spec.ts
  27. 2 2
      packages/llm/llm-pi-ai/README.i18n.yaml
  28. 2 2
      packages/llm/llm-pi-ai/README.md
  29. 2 2
      packages/llm/llm-pi-ai/README.zh.md
  30. 19 4
      packages/llm/llm-pi-ai/src/config.ts
  31. 24 2
      packages/llm/llm-pi-ai/src/discovery.ts
  32. 2 2
      packages/llm/llm-pi-ai/src/index.ts
  33. 24 0
      packages/llm/llm-pi-ai/tests/config.spec.ts
  34. 46 1
      packages/llm/llm-pi-ai/tests/discovery.spec.ts
  35. 2 2
      packages/llm/llm/README.i18n.yaml
  36. 5 0
      packages/llm/llm/README.md
  37. 5 0
      packages/llm/llm/README.zh.md
  38. 41 0
      packages/llm/llm/src/api-key.ts
  39. 9 0
      packages/llm/llm/src/error.ts
  40. 38 1
      packages/llm/llm/src/index.ts
  41. 70 0
      packages/llm/llm/tests/api-key.spec.ts

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.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-api-key-format-validation.md
+2026-08-06-api-key-format-validation.md: d1f6d31d362b76392514704be780f553b45d36ad
+2026-08-06-api-key-format-validation.zh.md: 75b3fa247bdf449964a874e909e6e3bc9e0694fa

+ 107 - 0
.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.md

@@ -0,0 +1,107 @@
+# Agent Note: Validate API key format before it reaches an HTTP header
+
+Status: implemented
+
+English | [中文](2026-08-06-api-key-format-validation.zh.md)
+
+## Problem
+
+An API key holding characters no HTTP header value can carry was accepted by every configuration surface and failed only when a request was built, far from the field that caused it.
+
+Pasting a key containing an emoji, CJK text, or a full-width punctuation mark into the web Models page reported a successful save. The first turn then failed with `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255` — the index and code point are UTF-16 internals with no action attached, and they disclose the code point of one character of the key. `llm-deepseek` produced this because `fetch` builds the `Bearer` header inside the `try` in [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts), whose `catch` labels every failure `TRANSPORT`; that label is in `DEFAULT_RETRYABLE_CODES`, so a permanent, deterministic fault was also retried three times.
+
+`llm-pi-ai` was worse on the same input. Its discovery probe builds the same header with a bare `fetch` in [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) and wrapped every failure as `could not reach <url>`, so a local key fault was reported as an unreachable network. The probe is reachable from the unsaved draft: `ProviderEditor` puts the typed `keyDraft` into its probe request, so the model-listing button sent an illegal key before anything was stored.
+
+Whitespace passed every check. `ProviderEditor` tested `keyDraft.length` and `resolveAdapterOptions` tested `config.apiKey.length`, so a key of three spaces stored and then authenticated as `Bearer` plus blanks. `llm-pi-ai` rejected an empty literal `apiKey` in `resolveProfiles`, but applied no check whatsoever to a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take.
+
+Sources: deepseek-harness#1594 and #1595; dsh-external#247, #249, #266, and #210.
+
+## Decision
+
+One rule defines a legal key: **after trimming, non-empty, and every character within `[\x21-\x7E]`** — printable ASCII, space excluded.
+
+This single predicate covers every input the sources list: empty, leading and trailing whitespace, interior whitespace, C0 control characters, emoji, CJK text, and full-width punctuation. It is also exactly the constraint that produced the ByteString failure, so the two issues close on one definition rather than on two coincidentally related fixes.
+
+A second, narrower rule catches a pasted environment line: input matching `^[A-Z][A-Z0-9_]*=[^=]` or wrapped in matching quotes is refused. Restricting the prefix to upper-case keeps real keys clear of it — `sk-` forms break the identifier match at the hyphen — and requiring a non-`=` character after the separator keeps base64 padding clear of it too. It reports the same format failure as an illegal character rather than its own message: the reader's next move is identical either way, so a separate line would name a cause without changing what to do.
+
+### Invariants belong at every layer; heuristics belong where the human is
+
+The charset rule is an invariant. A non-ASCII character *cannot* travel in a header value for any provider, so enforcing it in the browser, in each resolver, and on every credential read is consistent by construction rather than by agreement.
+
+The shape rule is a guess about how people paste, so it runs **only in the browser**. `llm-pi-ai` fronts OpenAI, Anthropic, and arbitrary hand-declared gateways whose key formats this repository does not own; a gateway issuing a key shaped like `TENANT1=abc` would, if the rule ran in the resolver, be locked out with no escape — the settings page would refuse it and a hand-written `.env` would be rejected on read. Confining the heuristic to the surface where the paste happens keeps the environment as the way through.
+
+### Absence is a configuration state, not a missing key
+
+"No API key" means three different things here, and only one of them is an error. The rule applies to a value that was *provided*; deciding whether one was provided at all stays with each caller.
+
+**Omitted.** A profile naming neither `apiKey` nor `apiKeyEnv` is authenticated by something other than a harness-held key. `routeAuth` in [provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) keeps the installed catalog provider's own auth precisely so provider-native ambient discovery survives, and `openai-codex` — shipped in that catalog — authenticates through OAuth and refuses an explicit key outright. `namesCredential` carries this distinction. In `llm-deepseek`, an absent `apiKey` likewise falls through to `apiKeyEnv`. Omission is never validated.
+
+**A blank field in the web UI.** The key input opens empty even for a provider whose key is already stored — the `keyStored` copy reads "Configured — enter a new value to replace" — so blank means *keep what is stored*. `ProviderEditor` skips `credentials.set` entirely when the draft is empty, and that stays a no-op: a blank field never blocks submit, or editing a base URL would demand re-entering the key.
+
+**Provided, but empty or whitespace-only.** What this means depends on what absence selects for that surface, and the two adapters differ for a reason. In `llm-pi-ai` it is an error, because absence there switches authentication mode — to the installed provider's ambient discovery or OAuth — so a blank key leaves genuine ambiguity about which was meant; its wording names the legitimate alternative rather than just refusing (*has an empty apiKey; omit it to use ambient authentication*). In `llm-deepseek` absence merely selects a different *source* for the same key, `apiKeyEnv`, so a blank literal resolves through that fallback exactly as an omitted one does. In the browser it is always a failure, on both cards: the field is where a person just typed, and silently discarding what they typed is never the right answer.
+
+`normalizeApiKey` therefore takes `string`, never `string | undefined`.
+
+### Where the rule lives
+
+`normalizeApiKey` is a module of the `dsh-llm` seam, beside [attribution.ts](../../../../packages/llm/llm/src/attribution.ts), which already owns shared header concerns. Both adapters depend on the seam and both need the rule, so it has two current consumers rather than a speculative one. It returns the trimmed value or a reason (`empty`, `illegalCharacters`).
+
+Both adapters also need the identical "refuse a stored credential" diagnosis, differing only by package prefix. `LlmError` is declared in the seam's `index.ts`, so `assertUsableApiKey(raw, pkg, ref)` lives there beside it and neither adapter carries a local copy. The predicate module stays dependency-free: importing `LlmError` into `api-key.ts` would cycle with `index.ts`'s re-export of it.
+
+The client cannot import any of this: client packages reference only client packages, so `packages/client/ui-models` mirrors the predicate in its own `apiKey.ts` and owns the localized messages, exactly as `validateDeepSeekModels` mirrors the host's `catalogModel` schema. Each side names the other in a comment.
+
+### What each surface does
+
+| Surface | Behavior |
+|---|---|
+| `dsh-llm` | Owns `normalizeApiKey`, `assertUsableApiKey`, and `INVALID_CREDENTIAL_CODE`, which is deliberately outside `DEFAULT_RETRYABLE_CODES`. |
+| `llm-deepseek` `resolveAdapterOptions` | Refuses a literal `apiKey` no header can carry, beside the other beyond-schema bounds; uses the trimmed value. An absent or blank one falls through to `apiKeyEnv`. |
+| `llm-deepseek` `resolveApiKey` | Normalizes what the credentials seam or environment returns, rejecting with `INVALID_CREDENTIAL` naming the Models page and never echoing the key. |
+| `llm-pi-ai` `resolveProfiles` | Applies the shared rule, keeping its "omit it to use ambient authentication" wording, and writes the trimmed value into the resolved profile. |
+| `llm-pi-ai` `resolveApiKey` | Normalizes the credential and environment paths. A profile naming no credential still returns `undefined`, so ambient and OAuth routes are unaffected. |
+| `llm-pi-ai` `discoverModels` | Normalizes before building the header, so an illegal key is a credential fault rather than an unreachable endpoint. A probe carrying no key stays unauthenticated. |
+| `ui-models` | Mirrors the charset rule, adds the shape heuristic, trims `keyDraft` before probe and `credentials.set`, and fixes the `stringAt` emptiness test. A blank field remains a no-op that submits; a field holding only whitespace is a field-level failure. Submit **and the endpoint interrogation** are both gated, so a refused key never spends a round trip to be told what the field already says, and the failure renders on the field, matching the existing `modelFailure` pattern. |
+
+`ProviderEditor` serves both the DeepSeek and pi-ai layouts, so one client change covers both providers. `CustomProviderCard` carries the same judgement for a hand-declared route.
+
+`credentials-local` is deliberately untouched. It stores credentials generally, and printable-ASCII is a constraint of HTTP headers rather than of credential storage; its existing refusal of values no dotenv style can represent stands as it was.
+
+## Alternatives considered
+
+**A `.pattern()` on the `apiKey` schema field.** Vendored schemastery supports it, and the pattern would serialize to the browser with the rest of the namespace schema — one rule, delivered rather than mirrored. It lost because a pattern cannot trim first: `cordis.yml` would then reject a padded key while `.env` tolerated one, and the resolver would disagree with the schema about the same string. Validating in `resolveAdapterOptions` keeps every surface trim-then-validate, and that function is already where this package re-judges bounds the schema cannot express.
+
+**A validation module shared by client and host.** Rejected by the source-plane layout: client packages reference only client packages plus `vendor/cordis` and `support/invariants`, and widening that to reach a host package would collide the two `Context` merges the split exists to keep apart. Mirroring a one-line predicate with a test on each side is the established shape here.
+
+**A per-adapter thrower in each of `llm-deepseek` and `llm-pi-ai`.** The first plan gave each adapter its own, differing only by the package prefix in the message, with a duplication-gate exemption to excuse the pair. Rejected before implementation: `LlmError` is declared in the seam, so the seam can own the diagnosis outright, and an exemption there would have hidden exactly the duplication it was covering for.
+
+**Sniffing the `TypeError` in the adapter's `catch`.** This would classify the ByteString failure after the fact, leaving the header construction itself unguarded. It depends on the wording of a Node error message, so it degrades silently across runtime versions, and it cannot help `llm-pi-ai`, whose request header is built inside the pi-ai SDK. Refusing the key before handing it over works for both adapters and for the discovery probe.
+
+**Enforcing in `credentials-local.set`.** It would catch every writer at once, including a hand-edited file. It lost because that provider stores credentials of every kind, and a rule derived from HTTP header encoding does not belong to it.
+
+**Running the shape heuristic in the resolvers too.** Symmetric, and it would stop a pasted environment line written directly into `.env`. Rejected for the lockout described above: a false positive in a resolver leaves the user no working path, while a false positive in the browser leaves the environment open.
+
+**Probing the provider at save time to prove the key works.** It would close the complaint the sources actually open with — a save that reports success and fails at the first turn. Rejected as out of scope and, on the code as it stood, unbuildable: `discoverModels` short-circuits to the installed catalog before any network call for exactly the providers pi-ai ships catalogs for, so it verified nothing about the key, and the DeepSeek card has no probe at all. A verifier's value is distinguishing "key rejected" from "cannot reach", which is the distinction this change makes reliable; building it first would have produced a verifier unable to tell its own outcomes apart. Comparable products also do not verify on save, so a blocking network call there would be an unexpected behavior rather than a missing one.
+
+## Consequences
+
+A malformed key is refused at the field that holds it, and a malformed stored key fails as `INVALID_CREDENTIAL` with a message naming where to fix it and no fragment of the key. Because that code sits outside `DEFAULT_RETRYABLE_CODES`, a deterministic credential fault is no longer retried three times as a transport blip. `llm-pi-ai` discovery reports an illegal probe key as a credential fault instead of an unreachable endpoint.
+
+The shape heuristic can refuse a real key. The first draft matched any upper-case identifier followed by `=`, which review showed was broader than intended: an all-upper-case base64 key ending in padding (`ABCD==`) matched an assignment it does not resemble. Requiring a non-`=` character after the separator excludes padding, since base64 only ever pads at the end. What remains — an upper-case name, one `=`, then a value — is a shape no known provider issues, and the rule runs only in the browser, so a user who still hits it can set the credential through the environment. The residual cost is a confusing refusal for a key nobody has yet reported.
+
+Restricting to printable ASCII is stricter than the transport requires: a header value may carry `\x80`–`\xFF`. Admitting latin-1 would let `é` through to return an opaque 401 instead of a local, explained refusal, so the stricter rule is deliberate. A provider that issues latin-1 keys would need this rule widened.
+
+The charset predicate exists twice, once per source plane. The layout forbids sharing it; each side carries its own test and names its twin.
+
+Keys already stored by an earlier build are read through `resolveApiKey`, so an illegal stored value fails at resolution rather than at request time. The diagnosis improves, but the failure moves earlier for anyone currently holding one.
+
+The costliest way to get this wrong would have been to treat absence as invalidity: a rule applied to `undefined` breaks every route authenticating through ambient discovery or OAuth, and a blank field that blocked submit makes editing any other setting demand re-entering the key. Both are pinned by tests rather than left to care.
+
+## Testing
+
+`packages/llm/llm/tests/api-key.spec.ts` drives `normalizeApiKey` and `assertUsableApiKey` over the whole input table — empty, whitespace-only, padded, interior-space, C0 control, emoji, CJK, full-width, latin-1, and the printable-ASCII boundary — and pins that a refusal carries `INVALID_CREDENTIAL` and no part of the key.
+
+`packages/llm/llm-deepseek/tests/` covers the literal-config path in `adapter.spec.ts` and the stored-credential path end to end in `dynamic-config.spec.ts`, through the real credentials seam rather than a stub. `packages/llm/llm-pi-ai/tests/` covers `resolveProfiles` — including that the trimmed value reaches the resolved profile, which the `...rest` spread would otherwise discard — and the discovery probe, including that a probe with no key sends no `authorization` header.
+
+`packages/client/ui-models/tests/` pins `apiKeyFailure` over the same table plus the paste-shape cases, and drives both cards: a blank field submits without writing a credential, a whitespace-only field fails on the field, an illegal or wrapped key blocks submit and the interrogation alike, a padded key is trimmed before `credentials.set` and before an interrogation, and a hand-declared route can be created with no key at all.
+
+The user-visible terminal state is pinned where it is actually assembled: `examples/headless-agent/tests/headless.snapshot.ts` runs the one-shot app against a stored key no header can carry, over the same keyless composition its missing-credential sibling uses, and records that the turn ends on `INVALID_CREDENTIAL` with an actionable message carrying neither the key nor the word `ByteString`. A package test could not have shown that, and the web e2e covers only the browser half.

+ 107 - 0
.agents/notes/implemented/bug-fix/2026-08-06-api-key-format-validation.zh.md

@@ -0,0 +1,107 @@
+# Agent Note: 在 API Key 进入 HTTP header 之前校验其格式
+
+Status: implemented
+
+[English](2026-08-06-api-key-format-validation.md) | 中文
+
+## Problem
+
+一个含有 HTTP header value 无法承载的字符的 API Key,曾被每一层配置界面接受,直到构造请求时才失败——离引发它的那个字段已经很远。
+
+把含 emoji、中文或全角标点的 Key 粘进 Web 模型设置页,保存会报成功。第一轮对话随即失败于 `Cannot convert argument to a ByteString because the character at index 7 has a value of 55357 which is greater than 255`——其中的下标与码点是 UTF-16 内部细节,不附带任何可执行动作,却泄露了 Key 中某一个字符的码点。`llm-deepseek` 之所以产出这句,是因为 `fetch` 在 [adapter.ts](../../../../packages/llm/llm-deepseek/src/adapter.ts) 的 `try` 内部构造 `Bearer` header,而那个 `catch` 把一切失败都标为 `TRANSPORT`;该标签又在 `DEFAULT_RETRYABLE_CODES` 之中,于是一个永久且确定的故障还会被重试三次。
+
+同样的输入在 `llm-pi-ai` 上更糟。它的探测路径在 [discovery.ts](../../../../packages/llm/llm-pi-ai/src/discovery.ts) 里用裸 `fetch` 构造同一个 header,并把一切失败包装成 `could not reach <url>`,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:`ProviderEditor` 把用户输入的 `keyDraft` 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。
+
+空白字符能通过每一道检查。`ProviderEditor` 判的是 `keyDraft.length`,`resolveAdapterOptions` 判的是 `config.apiKey.length`,于是三个空格构成的 Key 会被存下,随后以 `Bearer` 加若干空格去认证。`llm-pi-ai` 在 `resolveProfiles` 中拒绝空的字面量 `apiKey`,却对来自凭据或环境的 Key 完全不做检查——而那正是模型设置页写入的路径,也就是用户真正走的路径。
+
+来源:deepseek-harness#1594 与 #1595;dsh-external#247、#249、#266、#210。
+
+## Decision
+
+一条规则定义什么是合法 Key:**trim 之后非空,且每个字符都落在 `[\x21-\x7E]`**——可打印 ASCII,不含空格。
+
+这一个断言覆盖了来源列出的全部输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中文、全角标点。它同时正是造成 ByteString 失败的那条约束,所以两个 issue 收敛于同一个定义,而不是两个恰好相关的修复。
+
+第二条更窄的规则用于识别整行粘贴的环境变量:匹配 `^[A-Z][A-Z0-9_]*=[^=]` 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——`sk-` 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 `=` 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。
+
+### 不变量属于每一层,启发式属于人所在的那一层
+
+字符集规则是不变量。非 ASCII 字符对任何 provider 都**不可能**在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。
+
+形状规则是对人如何粘贴的猜测,因此**只在浏览器中运行**。`llm-pi-ai` 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 `TENANT1=abc` 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 `.env` 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。
+
+### 「没有 Key」是一种配置状态,不是缺失
+
+在这里,「没有 API Key」意味着三件完全不同的事,其中只有一件是错误。规则作用于**已提供**的值;至于究竟有没有提供,由各个调用方自行判断。
+
+**未指定。** 既不写 `apiKey` 也不写 `apiKeyEnv` 的 profile,是由 harness 所持有的 Key 之外的东西来鉴权的。[provider.ts](../../../../packages/llm/llm-pi-ai/src/provider.ts) 中的 `routeAuth` 保留内置 catalog provider 自身的鉴权,正是为了让 provider 原生的 ambient 发现得以存活;而该 catalog 附带的 `openai-codex` 通过 OAuth 鉴权,并会直接拒绝一个显式的 Key。`namesCredential` 承载着这一区分。在 `llm-deepseek` 中,缺省的 `apiKey` 同样会回落到 `apiKeyEnv`。未指定的情形永不参与校验。
+
+**Web UI 中留空的输入框。** 即便某个 provider 的 Key 已经存好,该输入框也是空着打开的——`keyStored` 的文案写的是「已配置——输入新值以替换」——所以留空意味着*保持已存储的值*。`ProviderEditor` 在草稿为空时完全跳过 `credentials.set`,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。
+
+**已提供,但为空或纯空白。** 它意味着什么,取决于「缺失」在该界面上选中了什么,而两个适配器的差异是有依据的。在 `llm-pi-ai` 中它是错误,因为那里的缺失切换的是**鉴权方式**——转向内置 provider 的 ambient 发现或 OAuth——因此一个空 Key 究竟想选哪一种是真有歧义;它的措辞指明了合法替代路径而非单纯拒绝(*has an empty apiKey; omit it to use ambient authentication*)。在 `llm-deepseek` 中,缺失只是为同一把 Key 选择了另一个**来源** `apiKeyEnv`,因此空白字面量会像缺省一样经该回落解析。在浏览器中它始终是失败,两张卡片皆然:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。
+
+因此 `normalizeApiKey` 接受 `string`,而绝非 `string | undefined`。
+
+### 规则住在哪里
+
+`normalizeApiKey` 是 `dsh-llm` seam 的一个模块,与已经承担共享 header 事务的 [attribution.ts](../../../../packages/llm/llm/src/attribution.ts) 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费者而非一个预设消费者。它返回 trim 后的值,或一个原因(`empty`、`illegalCharacters`)。
+
+两个适配器同样都需要那句完全相同的「拒绝一个已存储凭据」的诊断,差别仅在包名前缀。`LlmError` 声明在 seam 的 `index.ts` 中,因此 `assertUsableApiKey(raw, pkg, ref)` 就住在它旁边,两个适配器都不再各留一份。断言模块本身保持零依赖:把 `LlmError` 引入 `api-key.ts` 会与 `index.ts` 对它的再导出成环。
+
+客户端无法引入其中任何一个:client 包只 reference client 包,因此 `packages/client/ui-models` 在自己的 `apiKey.ts` 中镜像这个断言并持有本地化文案,正如 `validateDeepSeekModels` 镜像 host 侧的 `catalogModel` schema。两侧在注释中互相指名。
+
+### 各个界面各做什么
+
+| 界面 | 行为 |
+|---|---|
+| `dsh-llm` | 拥有 `normalizeApiKey`、`assertUsableApiKey` 与 `INVALID_CREDENTIAL_CODE`,后者刻意不进 `DEFAULT_RETRYABLE_CODES`。 |
+| `llm-deepseek` `resolveAdapterOptions` | 拒绝标头无法承载的字面量 `apiKey`,与其他超出 schema 的边界检查并排;使用 trim 后的值。缺省或空白的 `apiKey` 回落到 `apiKeyEnv`。 |
+| `llm-deepseek` `resolveApiKey` | 归一化凭据 seam 或环境返回的值,以 `INVALID_CREDENTIAL` 拒绝,消息指明模型设置页,绝不回显 Key。 |
+| `llm-pi-ai` `resolveProfiles` | 施加这条共享规则,保留其「omit it to use ambient authentication」的措辞,并把 trim 后的值写进解析后的 profile。 |
+| `llm-pi-ai` `resolveApiKey` | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 `undefined`,ambient 与 OAuth 路由不受影响。 |
+| `llm-pi-ai` `discoverModels` | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 |
+| `ui-models` | 镜像字符集规则,加入形状启发式,在探测与 `credentials.set` 之前 trim `keyDraft`,并修正 `stringAt` 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交**与端点探测**同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 `modelFailure` 模式一致。 |
+
+`ProviderEditor` 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个 provider。`CustomProviderCard` 为手工声明的路由承载同一套判定。
+
+`credentials-local` 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。
+
+## Alternatives considered
+
+**在 `apiKey` schema 字段上加 `.pattern()`。** vendor 中的 schemastery 支持它,且该 pattern 会随命名空间 schema 一同序列化到浏览器——一条规则,投递而非镜像。它落败于 pattern 无法先行 trim:那样 `cordis.yml` 会拒绝带首尾空白的 Key 而 `.env` 却容忍,resolver 与 schema 会对同一个字符串给出分歧。在 `resolveAdapterOptions` 中校验可以让每一层都是 trim-then-validate,而该函数本就是本包重新裁定 schema 无法表达的边界之处。
+
+**由 client 与 host 共享一个校验模块。** 被 source plane 布局否决:client 包只 reference client 包外加 `vendor/cordis` 与 `support/invariants`,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 `Context` 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。
+
+**在 `llm-deepseek` 与 `llm-pi-ai` 中各留一个抛错 helper。** 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:`LlmError` 声明在 seam 中,因此 seam 完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。
+
+**在适配器的 `catch` 中嗅探 `TypeError`。** 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 `llm-pi-ai`——后者的请求 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。
+
+**在 `credentials-local.set` 中执行。** 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该 provider 存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。
+
+**让形状启发式也在 resolver 中运行。** 更对称,且能拦住直接写进 `.env` 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。
+
+**在保存时探测 provider 以证明 Key 可用。** 它能关掉来源真正开篇抱怨的那件事——保存报成功、第一轮才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些 provider,`discoverModels` 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。
+
+## Consequences
+
+格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 `INVALID_CREDENTIAL` 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 `DEFAULT_RETRYABLE_CODES` 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。`llm-pi-ai` 的探测把非法 Key 报为凭据故障,而非端点不可达。
+
+形状启发式可能拒绝一个真实的 Key。最初的写法匹配任意「全大写标识符接 `=`」,评审指出其覆盖面比预期更宽:一个以 padding 结尾的全大写 base64 Key(`ABCD==`)会命中它并不像的赋值形态。要求分隔符之后必须是非 `=` 字符即可排除 padding——base64 的 padding 只出现在末尾。剩下的形态(大写名称、一个 `=`、然后是值)是已知 provider 不会签发的,且该规则只在浏览器中运行,因此仍撞上它的用户可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。
+
+限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 `\x80`–`\xFF` 的。放行 latin-1 会让 `é` 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个 provider 签发 latin-1 的 Key,这条规则需要放宽。
+
+字符集断言存在两份,每个 source plane 一份。布局禁止共享它;两侧各自带测试并在注释中指名其孪生体。
+
+早先版本已存下的 Key 会经 `resolveApiKey` 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。诊断变好了,但对当前正持有这类值的人而言,失败点提前了。
+
+把这件事做错的最大代价,会是把「未指定」当成「非法」:一条施加到 `undefined` 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由,而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都由测试钉住,而不是仅仰赖谨慎。
+
+## Testing
+
+`packages/llm/llm/tests/api-key.spec.ts` 以整张输入表驱动 `normalizeApiKey` 与 `assertUsableApiKey`——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中文、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 `INVALID_CREDENTIAL` 且不含 Key 的任何部分。
+
+`packages/llm/llm-deepseek/tests/` 在 `adapter.spec.ts` 中覆盖字面量配置路径,在 `dynamic-config.spec.ts` 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。`packages/llm/llm-pi-ai/tests/` 覆盖 `resolveProfiles`——包括 trim 后的值确实到达解析后的 profile,否则会被 `...rest` 展开丢弃——以及探测路径,包括不带 Key 的探测不会发出 `authorization` 标头。
+
+`packages/client/ui-models/tests/` 以同一张表加上形状用例钉住 `apiKeyFailure`,并驱动两张卡片:留空的输入框可提交且不写入凭据、只含空白的输入框在字段上失败、非法或被包裹的 Key 同时拦截提交与探测、带首尾空白的 Key 在 `credentials.set` 与探测之前被 trim,以及手工声明的路由可以完全不带 Key 创建。
+
+用户可见的终态则钉在它真正被组装的位置:`examples/headless-agent/tests/headless.snapshot.ts` 让 one-shot 应用在一个 HTTP 标头无法承载的已存密钥下运行,复用其 missing-credential 兄弟场景的同一套无密钥 composition,并记录该轮以 `INVALID_CREDENTIAL` 结束、消息可操作且既不含密钥也不含 `ByteString` 字样。包级测试无法证明这一点,而 web e2e 只覆盖了浏览器那一半。

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

@@ -78,6 +78,25 @@ describe('web e2e: Models settings page configures a dormant provider', () => {
     await compareOrRefreshGolden(EMPTY_EXPECTED, snapshot, MODE)
   }, 60_000)
 
+  it('refuses a key no HTTP header can carry before anything is written', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-models-illegal-key'))
+    const dialog = page.getByRole('dialog', { name: '设置' })
+    const key = dialog.getByLabel('API 密钥')
+    const save = dialog.getByRole('button', { name: '保存', exact: true })
+
+    // The paste that used to save cleanly and then fail the first turn with a
+    // ByteString TypeError now names the field that holds it.
+    await key.fill('sk-\u{1F600}minimax')
+    await dialog.getByText('该 API 密钥格式错误,请检查。').waitFor({ timeout: 10_000 })
+    await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(false)
+
+    // Clearing it restores submit: an empty field means "keep what is stored",
+    // never a refusal, or editing any other setting would demand the key.
+    await key.fill('')
+    await expect.poll(async () => save.isEnabled(), { timeout: 10_000 }).toBe(true)
+    expect(await dialog.getByText('该 API 密钥格式错误,请检查。').count()).toBe(0)
+  }, 60_000)
+
   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: '设置' })

+ 11 - 4
docs/config-catalog.md

@@ -669,8 +669,11 @@ Requires: `llm`
  */
 export interface Config {
   /**
-   * Trimmed literal API key; whitespace-only is absent. Prefer
-   * {@link apiKeyEnv} to keep secrets out of configuration files.
+   * Trimmed literal API key; whitespace-only is absent, so it resolves through
+   * {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep
+   * secrets out of configuration files. {@link resolveAdapterOptions} also
+   * format-checks what remains: a value no HTTP header can carry fails there
+   * rather than inside `fetch`.
    */
   apiKey?: string
   /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
@@ -729,7 +732,11 @@ export interface Config {
 
 /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
 export interface PiAiProviderProfile {
-  /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
+  /**
+   * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its
+   * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a
+   * value no HTTP header can carry fails there rather than inside `fetch`.
+   */
   apiKey?: string
   /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
   apiKeyEnv?: string
@@ -801,7 +808,7 @@ export interface PiAiModelProfile {
 
 Depends on: `CacheRetention` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
 
-Source: [`packages/llm/llm-pi-ai/src/config.ts:122`](../packages/llm/llm-pi-ai/src/config.ts)
+Source: [`packages/llm/llm-pi-ai/src/config.ts:126`](../packages/llm/llm-pi-ai/src/config.ts)
 
 ## `@deepseek-ai/dsh-llm-replay`
 

+ 2 - 2
docs/cordis-catalog/events.md

@@ -493,7 +493,7 @@ The provider topology changed: an adapter registered or unregistered routes, or
 'llm/adapters-updated'(): void
 ```
 
-Source: [`packages/llm/llm/src/index.ts:71`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts)
 
 ### `llm/stream` — waterfall
 
@@ -517,7 +517,7 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t
 
 Types: [GenerateOptions](../core-data-structures/core.md) · [LlmService](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/llm/llm/src/index.ts:60`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts)
 
 ## `session/*`
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -959,7 +959,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk>
 
 Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
-Source: [`packages/llm/llm/src/index.ts:255`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts)
 
 ## `ctx.permission` — `PermissionService`
 

+ 2 - 2
docs/event-producer-consumer.md

@@ -28,8 +28,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) |
 | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
 | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:141`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) |
-| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:71`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
-| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:60`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
+| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) |
+| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) |
 | `session/created` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |
 | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) |
 | `session/event` | `emit` | [`packages/core/session/src/index.ts:95`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |

+ 40 - 0
examples/headless-agent/tests/headless.snapshot.ts

@@ -31,6 +31,10 @@ const retryScenarioDir = join(snapshotsDir, 'provider-retry')
 const retryConfigPath = fileURLToPath(new URL('../retry.cordis.snapshot.yml', import.meta.url))
 const credentialsScenarioDir = join(snapshotsDir, 'missing-credential')
 const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url))
+// Same keyless composition as the missing-credential scenario: the endpoint is
+// never dialed either way, because a supplied-but-unusable key fails credential
+// resolution exactly where an absent one does.
+const invalidCredentialScenarioDir = join(snapshotsDir, 'invalid-credential')
 const ralphScenarioDir = join(snapshotsDir, 'ralph-loop')
 const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url))
 const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url))
@@ -254,6 +258,42 @@ describe('headless stream-json snapshots', () => {
     expect(normalized).toContain('as a last resort')
   }, LOADER_SMOKE_TEST_TIMEOUT_MS)
 
+  it('logs actionable invalid-credential guidance through the one-shot app', async () => {
+    const streamExpected = join(invalidCredentialScenarioDir, 'stream-json.expected.jsonl')
+    let runCwd = ''
+    const result = await runLoaderSmoke({
+      label: 'invalid-credential headless stream-json snapshot',
+      tempDirPrefix: 'headless-snapshot-invalid-credential-',
+      binScript,
+      configPath: credentialsConfigPath,
+      binArgs: ['--config', credentialsConfigPath, '--output-format', 'stream-json', 'say pong'],
+      tsconfigPath,
+      env: {
+        // A key that exists but no HTTP header can carry — the paste this
+        // change exists for. Before it, `fetch` refused to build the header
+        // and the turn ended on a retried ByteString TypeError.
+        DEEPSEEK_API_KEY: 'sk-\u{1F600}pasted-from-a-chat-window',
+        DEEPSEEK_BASE_URL: '',
+        NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
+      },
+      prepare: (cwd) => { runCwd = cwd },
+    })
+
+    expect(result.stderr).toBe('')
+    const normalized = normalizeHeadlessStream(result.stdout, runCwd)
+    if (refreshing) await writeFile(streamExpected, normalized)
+    expect(normalized).toBe(await readFile(streamExpected, 'utf8'))
+    // The durable failure names the reference to correct and the writer that
+    // usually owns it, and stays true in a composition that mounts no Models
+    // page at all.
+    expect(normalized).toContain('the API key resolved from DEEPSEEK_API_KEY contains characters')
+    expect(normalized).toContain('the web Models page writes it')
+    // Neither the key nor the transport-level symptom it used to produce may
+    // reach the user: the code point of one character is still the key.
+    expect(normalized).not.toContain('pasted-from-a-chat-window')
+    expect(normalized).not.toContain('ByteString')
+  }, LOADER_SMOKE_TEST_TIMEOUT_MS)
+
   it('logs the model default and a dynamic next-step reasoning effort', async () => {
     const result = await runLoaderSmoke({
       label: 'reasoning effort headless stream-json snapshot',

+ 12 - 0
examples/headless-agent/tests/snapshots/invalid-credential/stream-json.expected.jsonl

@@ -0,0 +1,12 @@
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"say pong","messageSeqs":[4],"source":{"kind":"fallback"}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":9,"time":0,"data":{"turn":1,"step":1}}}
+{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":10,"time":0,"data":{"turn":1,"reason":{"kind":"error","error":{"message":"llm-deepseek: the API key resolved from DEEPSEEK_API_KEY contains characters no HTTP header can carry; set DEEPSEEK_API_KEY to the raw key alone (the web Models page writes it)","code":"INVALID_CREDENTIAL"}}}}}
+{"type":"result","sessionId":"{{sessionId}}","output":""}

+ 2 - 2
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: 9d1fbdddd1ad9ec4c073dd1c0ca4ac7124c1b876
-README.zh.md: ff740bc6d1096901cbcc33772aff09deafeb53a4
+README.md: 80ae642ec9d6f91c78af041dda0b201959309577
+README.zh.md: 4236c8fec4f6d5e51363095d790944af9c08092a

A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/client/ui-models/README.md


A diferenza do arquivo foi suprimida porque é demasiado grande
+ 0 - 0
packages/client/ui-models/README.zh.md


+ 21 - 3
packages/client/ui-models/src/client/CustomProviderCard.tsx

@@ -18,6 +18,7 @@
 import { useState } from 'react'
 import type { ReactNode } from 'react'
 import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
+import { apiKeyFailure } from './apiKey.ts'
 import { EditorFooter } from './EditorFooter.tsx'
 import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx'
 import { ModelListEditor } from './ModelListEditor.tsx'
@@ -80,12 +81,22 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
   // bad row is named by its position here too. Capacities have route-level
   // fallbacks; what a route cannot default is at least one model.
   const modelFailure = validateDeepSeekModels(models)
+  const keyFailure = apiKeyFailure(keyDraft)
+  // The typed key with paste whitespace removed. A blank field yields an empty
+  // string, which the create path reads as "no key supplied" — a route may
+  // legitimately authenticate through the provider's own ambient discovery.
+  const keyValue = keyDraft.trim()
   const ready = route.length > 0 && !routeInvalid && !routeTaken
     && baseURL.length > 0 && models.length > 0 && modelFailure === undefined
+    && keyFailure === undefined
   // The one blocked gate worth a line under the form. The route id is omitted
   // because its own field already explains itself, and a satisfied card says
   // nothing at all rather than printing an empty paragraph.
   const hint = failure !== undefined || ready
+    // The key field prints its own failure directly beneath itself, so a card
+    // blocked only by the key stays silent here rather than answering with the
+    // next unmet gate — which is satisfied, and reads as a second, false fault.
+    || keyFailure !== undefined
     ? undefined
     : baseURL.length === 0
       ? t('customNeedsBaseUrl')
@@ -112,8 +123,8 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
       expectedRevision: openedAt,
     })
     if (!response.result.ok) return response.result.error.message
-    if (keyDraft.length > 0) {
-      const stored = await api.credentials.set({ ref: keyRef, value: keyDraft })
+    if (keyValue.length > 0) {
+      const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
       // The profile landed; saying the key did not is the only honest report,
       // and the row is now editable so the key can be entered again there.
       if (!stored.result.ok) return stored.result.error.message
@@ -208,6 +219,12 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           disabled={disabled}
           onChange={(event) => { setKeyDraft(event.target.value) }}
         />
+        {/* A create card has no stored key to keep, so the blank case says
+            what a blank field means here instead: this route may authenticate
+            through the provider's own ambient discovery or OAuth. */}
+        {keyFailure === undefined
+          ? null
+          : <p className={styles['error']}>{t(keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure)}</p>}
       </div>
       <ModelListEditor
         models={models}
@@ -216,8 +233,9 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
           settingsNs: NS,
           baseURL,
           api: protocol,
-          ...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
+          ...keyValue.length === 0 ? {} : { apiKey: keyValue },
         }}
+        probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure}
         api={api}
         t={t}
         disabled={disabled}

+ 11 - 2
packages/client/ui-models/src/client/ModelListEditor.tsx

@@ -74,6 +74,13 @@ export interface ModelListEditorProps {
   onReset?: () => void
   /** Endpoint facts for the fetch action. */
   probe: ProbeTarget
+  /**
+   * Copy key naming why the fetch action is unavailable, or `undefined` when
+   * it is. The card owns this because the key it would send is judged there:
+   * asking with a key the form has already refused spends a round trip to be
+   * told what the field already says.
+   */
+  probeBlocked?: keyof typeof en | undefined
   /** Wire face the fetch action calls. */
   api: Pick<IApiClient, 'llm'>
   /** Section copy. */
@@ -314,8 +321,10 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode {
         <button
           type="button"
           className={styles['linkButton']}
-          disabled={disabled || busy || !askable}
-          title={askable ? undefined : t('fetchNeedsBaseUrl')}
+          disabled={disabled || busy || !askable || props.probeBlocked !== undefined}
+          title={props.probeBlocked !== undefined
+            ? t(props.probeBlocked)
+            : askable ? undefined : t('fetchNeedsBaseUrl')}
           onClick={() => { void fetchModels() }}
         >
           {busy ? t('fetching') : t('fetchModels')}

+ 22 - 9
packages/client/ui-models/src/client/ProviderEditor.tsx

@@ -24,6 +24,7 @@ import {
 import {
   DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
 } from './DeepSeekModelsEditor.tsx'
+import { apiKeyFailure } from './apiKey.ts'
 import { EditorFooter } from './EditorFooter.tsx'
 import { ModelListEditor } from './ModelListEditor.tsx'
 import { deriveKeyRef, messageOf } from './store.ts'
@@ -168,15 +169,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
 
   const stringAt = (source: unknown, key: string): string | undefined => {
     const value = getPath(source, [key])
-    return typeof value === 'string' && value.length > 0 ? value : undefined
+    return typeof value === 'string' && value.trim().length > 0 ? value : undefined
   }
   const setField = (key: string, next: string | undefined): void => {
-    setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
+    // A value of nothing but whitespace is cleared, not stored: `stringAt`
+    // already reports it as absent, so the field would otherwise render empty
+    // while the draft still carried the spaces into `settings.yaml`, where
+    // both adapters would accept that non-empty string as a real value.
+    const value = next === undefined || next.trim().length === 0 ? undefined : next
+    setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value))
   }
 
   // The model list is validated by the same per-row checker for both families,
   // so a bad row is named by its position rather than by a blanket message.
   const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
+  const keyFailure = apiKeyFailure(keyDraft)
+  // What a probe or a write must carry: the typed key with paste whitespace
+  // removed. A blank field yields an empty string, which both call sites read
+  // as "no key supplied" rather than as a key — that is how a card whose
+  // provider already has a stored key is edited without re-entering it.
+  const keyValue = keyDraft.trim()
   // What the form currently shows, which is what an interrogation must ask:
   // an edited-but-unsaved endpoint, and a key typed but not yet stored.
   const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
@@ -188,7 +200,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
     provider: props.provider,
     ...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL },
     ...probeApi === undefined ? {} : { api: probeApi },
-    ...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
+    ...keyValue.length === 0 ? {} : { apiKey: keyValue },
   }
   /**
    * The write for this card, or a failure message. Every edit travels as
@@ -199,11 +211,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
    */
   const applyOnce = async (): Promise<string | undefined> => {
     const ns = namespace.ns
-    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 && normalizedKey.length > 0
+      && stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
       ? setPath(draft, ['apiKeyEnv'], keyRef)
       : draft
     {
@@ -240,8 +251,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
       setExpectedRevision(response.result.value.revision)
       setDraft(next)
     }
-    if (normalizedKey.length > 0) {
-      const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
+    if (keyValue.length > 0) {
+      const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
       if (!stored.result.ok) return stored.result.error.message
     }
     setKeyDraft('')
@@ -330,6 +341,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
             disabled={disabled || keyLocked}
             onChange={(event) => { setKeyDraft(event.target.value) }}
           />
+          {keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
         </div>
         <details className={styles['customized']}>
           <summary className={styles['customizedSummary']}>{t('customized')}</summary>
@@ -380,7 +392,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
                   defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
                 />
               )
-              : <ModelListEditor {...catalogProps} probe={probe} api={api} />}
+              : <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
           </div>
         </details>
       </>
@@ -413,7 +425,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
       <EditorFooter
         t={t}
         busy={busy}
-        submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined}
+        submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined
+          || keyFailure !== undefined}
         submitLabel="apply"
         submitBusyLabel="applying"
         onCancel={() => { props.onClose(false) }}

+ 58 - 0
packages/client/ui-models/src/client/apiKey.ts

@@ -0,0 +1,58 @@
+/**
+ * Browser-side judgement of a typed API key.
+ * @module @deepseek-ai/dsh-client-ui-models/apiKey
+ */
+
+/**
+ * Twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`: printable ASCII, space
+ * excluded. Client packages reference only client packages, so the charset
+ * rule is mirrored here rather than imported; keep the two in step, as
+ * `validateDeepSeekModels` is kept in step with the host's `catalogModel`.
+ */
+const LEGAL_API_KEY = /^[\x21-\x7E]+$/
+
+/**
+ * A pasted `NAME=value` environment line. Two narrowings keep real keys clear
+ * of it: the name must be upper-case, so `sk-` forms break at the hyphen, and
+ * the `=` must be followed by something other than another `=`, so base64
+ * padding on an all-upper-case key (`ABCD==`) is not mistaken for an
+ * assignment. This heuristic runs only here — a resolver applying it could
+ * lock a user out of a gateway whose key legitimately takes this shape, with
+ * the environment refusing it too and no way through.
+ */
+const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/
+
+/**
+ * Copy key naming why a typed key cannot be saved. A wrapped paste reports the
+ * same format failure as an illegal character: the reader's next move is the
+ * same either way — look at the key and paste it again — so naming the two
+ * causes apart would spend the field's one line on a distinction that changes
+ * nothing about what to do.
+ */
+export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters'
+
+/** Whether a value is wrapped in one matching pair of quotes. */
+function isQuoted(value: string): boolean {
+  const first = value[0]
+  if (first !== '"' && first !== '\'' && first !== '`') return false
+  return value.length > 1 && value.endsWith(first)
+}
+
+/**
+ * Judge the key input's current value.
+ *
+ * An empty field is not a failure: every card opens with it empty even when a
+ * key is already stored, where it means keep that one. A field holding only
+ * whitespace is a failure rather than an empty field, so typed input is never
+ * silently discarded.
+ * @param draft - the key input's current value, untrimmed.
+ * @returns the copy key for a field-level failure, or `undefined` to allow submit.
+ */
+export function apiKeyFailure(draft: string): ApiKeyFailureKey | undefined {
+  if (draft.length === 0) return undefined
+  const value = draft.trim()
+  if (value.length === 0) return 'keyBlank'
+  if (ENV_LINE.test(value) || isQuoted(value)) return 'keyIllegalCharacters'
+  if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters'
+  return undefined
+}

+ 6 - 0
packages/client/ui-models/src/client/locales.ts

@@ -53,6 +53,9 @@ export const en = {
   addModel: 'Add model',
   removeModel: 'Delete model',
   modelsEmpty: 'No models will be shown in the selector. Unlisted IDs can still be sent directly.',
+  keyBlank: 'Enter the API key, or leave the field empty to keep the stored one.',
+  keyBlankNew: 'Enter the API key, or leave the field empty if this provider authenticates another way.',
+  keyIllegalCharacters: 'This API key is not in a valid format. Please check it.',
   modelIdRequired: 'Model ID is required.',
   modelIdDuplicate: 'Model ID must be unique.',
   modelNameInvalid: 'Display name cannot be empty.',
@@ -144,6 +147,9 @@ export const zh: typeof en = {
   addModel: '添加模型',
   removeModel: '删除模型',
   modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
+  keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
+  keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。',
+  keyIllegalCharacters: '该 API 密钥格式错误,请检查。',
   modelIdRequired: '模型 ID 不能为空。',
   modelIdDuplicate: '模型 ID 不能重复。',
   modelNameInvalid: '显示名称不能为空。',

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

@@ -13,6 +13,7 @@ import { pathOps } from '../src/client/ProviderEditor.tsx'
 import {
   DeepSeekModelsEditor, formatCapacity, modelDrafts, parseCapacity, validateDeepSeekModels,
 } from '../src/client/DeepSeekModelsEditor.tsx'
+import { apiKeyFailure } from '../src/client/apiKey.ts'
 import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts'
 import type { ProviderRow } from '../src/client/store.ts'
 import { en } from '../src/client/locales.ts'
@@ -1228,3 +1229,54 @@ describe('ModelsSection', () => {
     expect(failure).toBe('connection lost')
   })
 })
+
+describe('apiKeyFailure', () => {
+  it('treats a blank field as no failure — it means keep the stored key', () => {
+    expect(apiKeyFailure('')).toBeUndefined()
+  })
+
+  it.each([
+    ['a printable-ASCII key', 'sk-0123456789'],
+    ['a padded key, which the caller trims', '  sk-abc  '],
+    ['the printable-ASCII boundary characters', '!~'],
+    ['a hyphenated key carrying an equals sign', 'sk-ABC=xyz'],
+    ['an all-upper-case key ending in base64 padding', 'ABCD=='],
+    ['an all-upper-case key ending in one padding character', 'MNOPQRST='],
+  ])('accepts %s', (_label, draft) => {
+    expect(apiKeyFailure(draft)).toBeUndefined()
+  })
+
+  it.each([
+    ['spaces', '   '],
+    ['a tab', '\t'],
+  ])('fails a field holding only %s instead of silently dropping it', (_label, draft) => {
+    expect(apiKeyFailure(draft)).toBe('keyBlank')
+  })
+
+  it.each([
+    ['an emoji', 'sk-\u{1F600}'],
+    ['CJK text', 'sk-你好'],
+    ['full-width punctuation', 'sk-abc,'],
+    ['an interior space', 'sk-abc def'],
+    ['a C0 control character', 'sk-abc\x01'],
+    ['a latin-1 character', 'sk-café'],
+  ])('fails %s as illegal characters', (_label, draft) => {
+    expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
+  })
+
+  it.each([
+    ['a pasted environment line', 'DEEPSEEK_API_KEY=sk-abc'],
+    ['double quotes', '"sk-abc"'],
+    ['single quotes', '\'sk-abc\''],
+    ['backticks', '`sk-abc`'],
+  ])('fails %s as a format failure', (_label, draft) => {
+    expect(apiKeyFailure(draft)).toBe('keyIllegalCharacters')
+  })
+
+  it('needs a matching closing quote before it calls a value wrapped', () => {
+    // A lone quote and an unbalanced one are legal printable ASCII, so the
+    // heuristic leaves them alone rather than guessing at a paste error.
+    expect(apiKeyFailure('"')).toBeUndefined()
+    expect(apiKeyFailure('"a')).toBeUndefined()
+  })
+})

+ 164 - 0
packages/client/ui-models/tests/provider-form.spec.tsx

@@ -863,6 +863,170 @@ describe('hand-declared providers', () => {
     expect(screen.getByRole('button', { name: en.customAdd })).toBeTruthy()
   })
 
+  it('refuses an unusable key on the field and blocks creation', () => {
+    const { mutate, set } = mountCard()
+
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
+
+    // A hand-declared route reaches the same judgement as an edited one, so a
+    // key that no header can carry never becomes a profile plus a bad secret.
+    expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
+    expect(buttonNamed(en.create).disabled).toBe(true)
+    expect(mutate).not.toHaveBeenCalled()
+    expect(set).not.toHaveBeenCalled()
+  })
+
+  it('stays silent about the other gates when only the key is refused', () => {
+    mountCard()
+
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
+
+    // Route, endpoint, and models are all satisfied, so answering with the
+    // next unmet gate would print a second, false fault beside the real one.
+    expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
+    expect(screen.queryByText(en.customNeedsModels)).toBeNull()
+    expect(screen.queryByText(en.customNeedsBaseUrl)).toBeNull()
+  })
+
+  it('tells a whitespace-only key what a blank field means on a create card', () => {
+    const { mutate } = mountCard()
+
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme-gateway' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '   ' } })
+
+    // There is no stored key to keep here, so the blank case says the thing
+    // that is true of a route being declared: it may authenticate elsewhere.
+    expect(screen.getByText(en.keyBlankNew)).toBeTruthy()
+    expect(screen.queryByText(en.keyBlank)).toBeNull()
+    expect(buttonNamed(en.fetchModels).title).toBe(en.keyBlankNew)
+    expect(buttonNamed(en.create).disabled).toBe(true)
+    expect(mutate).not.toHaveBeenCalled()
+  })
+
+  it('creates without a key when the route authenticates some other way', async () => {
+    const { set, onClose } = mountCard()
+
+    fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'ambient-gateway' } })
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://gateway.acme.example/v1' } })
+    fireEvent.click(screen.getByRole('button', { name: en.addModel }))
+    fireEvent.change(screen.getByLabelText(`${en.modelId} 1`), { target: { value: 'acme-large' } })
+    fireEvent.click(screen.getByText(en.create))
+
+    await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) })
+    expect(set).not.toHaveBeenCalled()
+  })
+})
+
+describe('API key field', () => {
+  it('submits with a blank key field without writing a credential', async () => {
+    const { mutate, set } = await mountSection()
+    openEditor('openai')
+
+    // The field opens empty even for a provider whose key is stored, where it
+    // means "keep that one" — so editing anything else must not require it.
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://moved.example/v1' } })
+    expect(buttonNamed(en.apply).disabled).toBe(false)
+    fireEvent.click(screen.getByText(en.apply))
+
+    await waitFor(() => { expect(mutate).toHaveBeenCalled() })
+    expect(set).not.toHaveBeenCalled()
+  })
+
+  it('clears a whitespace-only base URL instead of writing the spaces', async () => {
+    const { mutate } = await mountSection()
+    openEditor('openai')
+
+    // The field renders this as empty, so the draft must agree: storing the
+    // spaces would hand both adapters a non-empty string they accept as a URL.
+    fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: '   ' } })
+    fireEvent.click(screen.getByText(en.apply))
+
+    await waitFor(() => { expect(mutate).toHaveBeenCalled() })
+    const ops = firstMutate(mutate).ops
+    expect(ops.some(op => op.op === 'set' && op.path.includes('baseURL'))).toBe(false)
+    expect(ops.some(op => op.op === 'unset' && op.path.includes('baseURL'))).toBe(true)
+  })
+
+  it('blocks submit and names the field when the key holds only whitespace', async () => {
+    const { mutate, set } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '   ' } })
+
+    expect(screen.getByText(en.keyBlank)).toBeTruthy()
+    expect(buttonNamed(en.apply).disabled).toBe(true)
+    expect(mutate).not.toHaveBeenCalled()
+    expect(set).not.toHaveBeenCalled()
+  })
+
+  it('blocks submit when the key contains characters no header can carry', async () => {
+    const { set } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
+
+    expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
+    expect(buttonNamed(en.apply).disabled).toBe(true)
+    expect(set).not.toHaveBeenCalled()
+  })
+
+  it('blocks submit when a whole NAME=value line was pasted', async () => {
+    await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } })
+
+    expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
+    expect(buttonNamed(en.apply).disabled).toBe(true)
+  })
+
+  it('trims a padded key before storing it', async () => {
+    const { set } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '  sk-abc  ' } })
+    expect(buttonNamed(en.apply).disabled).toBe(false)
+    fireEvent.click(screen.getByText(en.apply))
+
+    await waitFor(() => { expect(set).toHaveBeenCalled() })
+    expect((set.mock.calls[0]?.[0] as { value: string }).value).toBe('sk-abc')
+  })
+
+  it('blocks the interrogation too, rather than spending a round trip on a refused key', async () => {
+    const { discover } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'sk-\u{1F600}' } })
+
+    // The host would refuse this before building the header anyway; asking is
+    // a round trip to be told what the field already says.
+    expect(buttonNamed(en.fetchModels).disabled).toBe(true)
+    expect(buttonNamed(en.fetchModels).title).toBe(en.keyIllegalCharacters)
+    expect(discover).not.toHaveBeenCalled()
+  })
+
+  it('carries the trimmed key into an interrogation, not the padded draft', async () => {
+    const { discover } = await mountSection()
+    openEditor('openai')
+
+    fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: '  sk-abc  ' } })
+    fireEvent.click(screen.getByRole('button', { name: en.fetchModels }))
+
+    await waitFor(() => { expect(discover).toHaveBeenCalled() })
+    expect(firstProbe(discover)).toMatchObject({ apiKey: 'sk-abc' })
+  })
+
   it('reloads the section after creating a hand-declared provider', async () => {
     const { controller, mutate } = await mountSection()
     const load = vi.spyOn(controller, 'load')

+ 2 - 2
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: b583ecadf23ec4d089bfc9473dc1165c3e70ae9a
-README.zh.md: 42d38e913b98b9ed2cf1781fdc6f716a0050c905
+README.md: c6435d0bdfbb9758b6f86ef38e94159a9ccdc36d
+README.zh.md: f37286ade023ceecf6bfc87eb08fd4d80a5a3912

+ 1 - 1
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 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.
+- **`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. Every key is format-checked before use — a literal at connection-facts resolution (plugin load, or the next settings snapshot), a stored or ambient value at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time check throws `LlmError('INVALID_CREDENTIAL')` naming the failing entry point but never any part of the key. 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.
 

+ 1 - 1
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 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
+- **`ctx.credentials`**——API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:去除首尾空白后非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后——仅在未挂载 seam 时——读取原始环境变量。纯空白字面值会被视为缺失,而不会成为 Authorization 值。由于凭据事实与连接事实同行,被 resolver 拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥:整个先前世代继续服务。每个密钥在使用前都会被校验格式——字面量在连接事实解析时(插件加载或下一次 settings 快照)校验,已存储的值或环境变量值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时校验会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的入口,但绝不透露密钥的任何部分。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败,并点名每个配置入口,同时路由保持注册、catalog 保持可浏览——首次运行的上手流程就是「浏览模型、存入密钥、再次发起提示」,中间无需任何重启。
 
 唯一在注册期捕获的事实是重试策略:其解析值变化时,插件原地重新注册该路由(同一适配器实例、一个同步区段),因此 `ctx.llm.providerRetryPolicy('deepseek-official')` 始终报告当前策略。
 

+ 26 - 7
packages/llm/llm-deepseek/src/index.ts

@@ -13,7 +13,7 @@
 
 import type { Context } from 'cordis'
 import z from 'schemastery'
-import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
+import { assertUsableApiKey, LlmError, normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
 import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
 import { credentialRef } from '@deepseek-ai/dsh-credentials'
 import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -59,8 +59,11 @@ const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
  */
 export interface Config {
   /**
-   * Trimmed literal API key; whitespace-only is absent. Prefer
-   * {@link apiKeyEnv} to keep secrets out of configuration files.
+   * Trimmed literal API key; whitespace-only is absent, so it resolves through
+   * {@link apiKeyEnv} like an omitted one. Prefer {@link apiKeyEnv} to keep
+   * secrets out of configuration files. {@link resolveAdapterOptions} also
+   * format-checks what remains: a value no HTTP header can carry fails there
+   * rather than inside `fetch`.
    */
   apiKey?: string
   /** Credential reference (environment-variable name) resolved per request; defaults to `DEEPSEEK_API_KEY`. */
@@ -156,7 +159,6 @@ 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') {
@@ -178,8 +180,25 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
       `llm-deepseek: streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
     )
   }
+  // An absent apiKey is not a failure: it falls through to apiKeyEnv below.
+  // A supplied one must be usable, so a malformed literal fails here beside
+  // the other beyond-schema bounds instead of inside `fetch`.
+  // Absence is not a failure, and a blank literal is absence: both resolve
+  // through apiKeyEnv below, which is this adapter's defined fallback. (The
+  // pi-ai adapter refuses a blank one instead, because there absence selects a
+  // different authentication mode rather than a different source for the same
+  // key.) What a literal cannot be is unusable: a value no HTTP header can
+  // carry fails here beside the other beyond-schema bounds, not inside `fetch`.
+  let apiKey: string | undefined
+  if (config.apiKey !== undefined) {
+    const checked = normalizeApiKey(config.apiKey)
+    if (!checked.ok && checked.reason === 'illegalCharacters') {
+      throw new Error('llm-deepseek: apiKey contains characters no HTTP header can carry; paste the raw key only')
+    }
+    apiKey = checked.ok ? checked.value : undefined
+  }
   return {
-    ...apiKey !== undefined && apiKey.length > 0 ? { apiKey } : {},
+    ...apiKey === undefined ? {} : { apiKey },
     apiKeyEnv: credentialRef(config.apiKeyEnv ?? DEFAULT_API_KEY_ENV),
     baseURL: config.baseURL ?? process.env.DEEPSEEK_BASE_URL ?? PUBLIC_BASE_URL,
     defaults: {
@@ -227,12 +246,12 @@ export function apply(ctx: Context, config: Config): void {
     const credentials = ctx.get('credentials')
     if (credentials !== undefined) {
       const hit = await credentials.resolve(ref)
-      if (hit !== undefined) return hit.value
+      if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref)
     } else {
       // Without the seam, keep the historical ambient fallback so a plain
       // cordis.yml composition works from the environment alone.
       const ambient = process.env[ref]
-      if (ambient !== undefined && ambient.length > 0) return ambient
+      if (ambient !== undefined && ambient.length > 0) return assertUsableApiKey(ambient, 'llm-deepseek', ref)
     }
     throw new LlmError(
       `llm-deepseek: no API key for provider route "${PROVIDER}"; store ${ref} through the credentials`

+ 35 - 0
packages/llm/llm-deepseek/tests/adapter.spec.ts

@@ -998,3 +998,38 @@ describe('plugin registration and config', () => {
     expect(ctx.llm.listProviders()).toEqual([])
   })
 })
+
+describe('API key format', () => {
+  it('trims a padded literal apiKey', () => {
+    expect(resolveAdapterOptions({ apiKey: '  sk-abc  ' }).apiKey).toBe('sk-abc')
+  })
+
+  it('leaves an omitted apiKey absent so apiKeyEnv still resolves it', () => {
+    expect(resolveAdapterOptions({}).apiKey).toBeUndefined()
+  })
+
+  it('treats a whitespace-only literal apiKey as absent, not as a failure', () => {
+    // This adapter's absence has a defined fallback, so a blank literal
+    // resolves through apiKeyEnv like an omitted one. (llm-pi-ai refuses a
+    // blank one instead: there, absence selects provider-native or OAuth
+    // authentication rather than a different source for the same key.)
+    const resolved = resolveAdapterOptions({ apiKey: '   ', apiKeyEnv: 'CUSTOM_API_KEY' })
+    expect(resolved.apiKey).toBeUndefined()
+    expect(resolved.apiKeyEnv).toBe('CUSTOM_API_KEY')
+  })
+
+  it('rejects a literal apiKey no header can carry', () => {
+    expect(() => resolveAdapterOptions({ apiKey: 'sk-\u{1F600}' }))
+      .toThrow(/no HTTP header can carry/)
+  })
+
+  it('never echoes the key in the rejection', () => {
+    const secret = 'sk-\u{1F600}supersecret'
+    expect(() => resolveAdapterOptions({ apiKey: secret })).toThrow()
+    try {
+      resolveAdapterOptions({ apiKey: secret })
+    } catch (error) {
+      expect((error as Error).message).not.toContain('supersecret')
+    }
+  })
+})

+ 20 - 1
packages/llm/llm-deepseek/tests/dynamic-config.spec.ts

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
 import { mkdtemp, rm, writeFile } from 'node:fs/promises'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import LlmService from '@deepseek-ai/dsh-llm'
+import LlmService, { INVALID_CREDENTIAL_CODE } from '@deepseek-ai/dsh-llm'
 import { credentialRef } from '@deepseek-ai/dsh-credentials'
 import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local'
 import { settingsNamespace } from '@deepseek-ai/dsh-settings'
@@ -103,6 +103,25 @@ describe('request-level dynamic configuration', () => {
     expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
   })
 
+  it('rejects a stored credential no header can carry, never echoing it in the failure', async () => {
+    vi.stubEnv('DEEPSEEK_API_KEY', '')
+    const dir = await home()
+    const { ctx } = await boot(dir, { baseURL: 'http://127.0.0.1:1' })
+    const secret = 'sk-\u{1F600}supersecret'
+
+    // The real credentials seam (the path the web Models page writes through),
+    // not a hand-built stub: this package's own dynamic-config harness already
+    // boots one, and round-tripping the value through its actual store/read
+    // path is stronger evidence than a canned in-memory return would be.
+    await ctx.credentials.set(KEY_REF, secret)
+    const result = await prompt(ctx)
+    expect(result.finish).toMatchObject({ kind: 'error', failure: { code: INVALID_CREDENTIAL_CODE } })
+    if (result.finish.kind !== 'error') throw new Error('expected an error finish')
+    expect(result.finish.failure.message).not.toContain(secret)
+    expect(result.finish.failure.message).not.toContain('supersecret')
+    expect(result.finish.failure.message).not.toContain('ByteString')
+  })
+
   it('advertises a live settings catalog without re-registration', async () => {
     const dir = await home()
     const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })

+ 2 - 2
packages/llm/llm-pi-ai/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
-README.md: af0e952dd8dbd9767b98229ee6b87262007d6738
-README.zh.md: f8a19999f08aa8a6963874d57bf74370797b951c
+README.md: 0dcf15d6caf365a1f8e75088cb363eaa6560a6ec
+README.zh.md: 79be5d320c0f4411f7cf8a0bd72c887048929dcb

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

@@ -67,7 +67,7 @@ Resolution still fails loud, naming the offending route and model, when a route
 
 The adapter reads its profiles through a thunk **once per operation** instead of freezing them at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged.
 
-Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving.
+Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every key is trimmed and format-checked before use — a literal `apiKey` when profiles resolve (plugin load, or the next settings snapshot), a value `apiKeyEnv` resolves at request time — so a value no HTTP header can carry is refused there instead of surfacing as an opaque `fetch` `TypeError`; the request-time refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving.
 
 The adapter exposes each configured route's models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata read from the same pi-ai `Models` collection the request path uses, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, configured output cap, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers. A model's **configured** `maxTokens` becomes the seam's `defaultMaxTokens`, so a request that names no output cap carries the one the deployment chose; a value inherited from the installed catalog is the model's output *capability* and never becomes a request default on its own.
 
@@ -85,7 +85,7 @@ The plugin offers `ctx.llm.registerModelDiscovery('llm-pi-ai', …)`, which answ
 
 A request naming a route the **installed catalog ships is answered from that catalog**, with no network call: pi-ai's registry is the authoritative list for its own providers, and it carries the context windows and output caps a listing endpoint would not disclose. Such a route needs no `baseURL` at all. Only a route the catalog does not describe — a gateway, a self-hosted server — is interrogated over the wire, and one that names no endpoint is told to set one or enter its models by hand.
 
-A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all.
+A draft carries the credential the user typed, if any; a route that already stored one shows a configuration surface only a redacted descriptor, so the interrogation supplies that route's own credential — resolved exactly as a request to it would, `apiKey` then `apiKeyEnv` — rather than going out unauthenticated and reporting the endpoint's 401 as a wrong key. A typed key wins, being the one under test. Resolution happens only on the path that reaches the network, so a catalog route answers without touching credentials at all. A supplied or stored probe key is trimmed and format-checked the same way, so a value no HTTP header can carry is refused immediately as `LlmError('INVALID_CREDENTIAL')` instead of reaching `fetch`, where it would surface as an opaque `ByteString` failure indistinguishable from an unreachable endpoint.
 
 Interrogation reads `openai-completions` and `openai-responses`, whose `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; every other protocol answers `DISCOVERY_UNSUPPORTED` so the surface falls back to hand-entry instead of an authentication failure being reported as a provider with no models. The `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments.
 

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

@@ -67,7 +67,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
 
 适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。
 
-凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。
+凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个密钥在使用前都会被去除首尾空白并校验格式——字面 `apiKey` 在 profile 解析时(插件加载,或下一次 settings 快照)校验,`apiKeyEnv` 解析出的值则在请求时校验——因此 HTTP 标头无法承载的值会在这一步被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;请求时的拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(协议面将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。
 
 适配器通过 `ctx.llm.listModels(provider)` 公开每条已配置路由的模型。这是从请求路径所用的同一个 pi-ai `Models` 集合读取的提供方无关 selector 元数据,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口、已配置输出上限和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。模型**已配置**的 `maxTokens` 会成为 seam 的 `defaultMaxTokens`,因此未点名输出上限的请求会携带部署选定的那一个;而从已安装 catalog 继承来的值是模型的输出**能力**,绝不会自行变成请求默认值。
 
@@ -85,7 +85,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
 
 点名了**已安装 catalog 所提供路由**的请求,直接由该 catalog 作答,完全不联网:pi-ai 的注册表才是它自家提供方的权威列表,且携带列表端点不会公布的上下文窗口与输出上限。这类路由根本不需要 `baseURL`。只有 catalog 未描述的路由——网关、自建服务——才会经协议层询问;若它也没给端点,则会被告知去设置一个或手工填写模型。
 
-草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。
+草稿携带的是用户当下键入的凭据(如果有);已经存好凭据的路由,在配置界面上只呈现一个脱敏描述符,因此询问会自行取用该路由的凭据——解析方式与向它发请求时完全一致,先 `apiKey` 后 `apiKeyEnv`——而不是不带认证发出去、再把端点的 401 报成密钥不对。键入的密钥优先,因为那正是被测试的那一把。解析只发生在真正要联网的路径上,因此 catalog 路由作答时完全不会触碰凭据。用户提供或已存储的探测密钥也会经过同样的去除空白与格式校验:HTTP 标头无法承载的值会被立即以 `LlmError('INVALID_CREDENTIAL')` 拒绝,而不会传到 `fetch`——否则会呈现为一个和端点不可达难以区分的、语义不明的 `ByteString` 失败。
 
 询问只读 `openai-completions` 与 `openai-responses`,它们「`GET /models` + bearer 认证」的形状是网关、自建服务与官方端点三方一致认可的那一种。Azure 尽管出身 OpenAI 也被排除——它用 `api-key` 标头认证并要求 `api-version` 查询参数——Codex 则走 OAuth;其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把认证失败报成一个没有模型的提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。
 

+ 19 - 4
packages/llm/llm-pi-ai/src/config.ts

@@ -19,7 +19,7 @@ import z from 'schemastery'
 import { credentialRef } from '@deepseek-ai/dsh-credentials'
 import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
 import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
-import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
+import { normalizeApiKey, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
 import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
 import { resolveRouteModels } from './catalog.ts'
 import type { PiAiModelProfile } from './catalog.ts'
@@ -38,7 +38,11 @@ export type { PiAiModelProfile } from './catalog.ts'
 
 /** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
 export interface PiAiProviderProfile {
-  /** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
+  /**
+   * Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its
+   * provider-native ambient discovery. Trimmed and format-checked by {@link resolveProfiles}; a
+   * value no HTTP header can carry fails there rather than inside `fetch`.
+   */
   apiKey?: string
   /** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
   apiKeyEnv?: string
@@ -220,8 +224,18 @@ export function resolveProfiles(
   for (const [provider, source] of entries) {
     rejectRemovedFields(provider, source)
     if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
-    if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
-      throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`)
+    // Omission selects the installed provider's own auth — ambient discovery
+    // or OAuth — so only a supplied key is judged.
+    let apiKey: string | undefined
+    if (source.apiKey !== undefined) {
+      const checked = normalizeApiKey(source.apiKey)
+      if (!checked.ok) {
+        throw new Error(checked.reason === 'empty'
+          ? `llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`
+          : `llm-pi-ai: provider "${provider}" has an apiKey containing characters no HTTP header can carry;`
+            + ' paste the raw key only')
+      }
+      apiKey = checked.value
     }
     if (source.baseURL !== undefined && source.baseURL.length === 0) {
       throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
@@ -252,6 +266,7 @@ export function resolveProfiles(
     const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source
     resolved.set(provider, {
       ...rest,
+      ...apiKey === undefined ? {} : { apiKey },
       provider,
       displayName,
       ...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },

+ 24 - 2
packages/llm/llm-pi-ai/src/discovery.ts

@@ -22,7 +22,7 @@
  * @module dsh-llm-pi-ai/discovery
  */
 
-import { LlmError } from '@deepseek-ai/dsh-llm'
+import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm'
 import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm'
 import { attributionHeaders } from '@deepseek-ai/dsh-llm'
 import { catalogModels } from './catalog.ts'
@@ -161,6 +161,25 @@ function readListing(body: unknown): LlmDiscoveredModel[] {
   return models
 }
 
+/**
+ * Accept one probe key, or refuse it before the header is built. Without this
+ * the `fetch` below would throw a ByteString `TypeError` that this function's
+ * catch reports as `could not reach <url>` — blaming the network for a local,
+ * deterministic fault.
+ * @param raw - the key typed into the form or read from storage.
+ * @returns the trimmed, usable key.
+ */
+function usableProbeKey(raw: string): string {
+  const checked = normalizeApiKey(raw)
+  if (checked.ok) return checked.value
+  throw new LlmError(
+    checked.reason === 'empty'
+      ? 'this provider\'s API key is blank; enter it on the Models page, or clear it to probe unauthenticated'
+      : 'this provider\'s API key contains characters no HTTP header can carry; paste the raw key only',
+    INVALID_CREDENTIAL_CODE,
+  )
+}
+
 /**
  * Interrogate one draft provider endpoint for the models it advertises.
  * @param request - the endpoint, protocol, and one-shot credential to use.
@@ -216,7 +235,10 @@ export async function discoverModels(
   // stored one is only asked for here, past the catalog short-circuit and the
   // protocol check, so a route answered from the registry costs no credential
   // lookup — and no diagnostic about a credential it never needed.
-  const apiKey = request.apiKey ?? await storedApiKey?.()
+  // A probe carrying no key stays unauthenticated, which is how a route that
+  // relies on the provider's own ambient discovery is meant to be asked.
+  const supplied = request.apiKey ?? await storedApiKey?.()
+  const apiKey = supplied === undefined ? undefined : usableProbeKey(supplied)
   let response: Response
   try {
     response = await fetch(url, {

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

@@ -43,7 +43,7 @@
  */
 
 import type { Context } from 'cordis'
-import { LlmError } from '@deepseek-ai/dsh-llm'
+import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm'
 import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
 import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
 import { PiAiAdapter } from './adapter.ts'
@@ -145,7 +145,7 @@ export function apply(ctx: Context, config: Config): void {
       // Without the seam, read exactly the named variable so a plain
       // cordis.yml composition works from the environment alone.
       : process.env[ref]
-    if (hit !== undefined && hit.length > 0) return hit
+    if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref)
     throw new LlmError(
       `llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`
       + ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,`

+ 24 - 0
packages/llm/llm-pi-ai/tests/config.spec.ts

@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest'
+import { resolveProfiles } from '../src/config.ts'
+
+describe('API key format', () => {
+  it('trims a padded literal apiKey into the resolved profile', () => {
+    const resolved = resolveProfiles({ openai: { apiKey: '  sk-abc  ', baseURL: 'https://acme.test' } })
+    expect(resolved.get('openai')?.apiKey).toBe('sk-abc')
+  })
+
+  it('keeps an omitted apiKey absent so ambient authentication still applies', () => {
+    const resolved = resolveProfiles({ openai: { baseURL: 'https://acme.test' } })
+    expect(resolved.get('openai')?.apiKey).toBeUndefined()
+  })
+
+  it('still tells an empty apiKey to omit itself', () => {
+    expect(() => resolveProfiles({ openai: { apiKey: '   ', baseURL: 'https://acme.test' } }))
+      .toThrow(/omit it to use ambient authentication/)
+  })
+
+  it('rejects an apiKey no header can carry', () => {
+    expect(() => resolveProfiles({ openai: { apiKey: 'sk-\u{1F600}', baseURL: 'https://acme.test' } }))
+      .toThrow(/no HTTP header can carry/)
+  })
+})

+ 46 - 1
packages/llm/llm-pi-ai/tests/discovery.spec.ts

@@ -1,6 +1,6 @@
 import { createServer } from 'node:http'
 import type { IncomingMessage, Server, ServerResponse } from 'node:http'
-import { afterEach, describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
 import { Context } from 'cordis'
 import LlmService, { userAgent } from '@deepseek-ai/dsh-llm'
 import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
@@ -12,6 +12,9 @@ const servers: Server[] = []
 const touchedEnv: string[] = []
 
 afterEach(async () => {
+  // A no-op when the test never stubbed `fetch`; only 'probe key format'
+  // below installs one.
+  vi.unstubAllGlobals()
   for (const name of touchedEnv.splice(0)) Reflect.deleteProperty(process.env, name)
   await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve))))
 })
@@ -311,3 +314,45 @@ describe('draft-provider model discovery', () => {
       .rejects.toMatchObject({ code: 'NO_DISCOVERY' })
   })
 })
+
+describe('probe key format', () => {
+  it('reports an illegal probe key as a credential fault, not an unreachable endpoint', async () => {
+    await expect(discoverModels({
+      baseURL: 'https://acme.test',
+      api: 'openai-completions',
+      apiKey: 'sk-\u{1F600}',
+    })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
+  })
+
+  it('reports a blank probe key as a credential fault too', async () => {
+    // The Models page omits `apiKey` entirely for a cleared field rather than
+    // sending '', so this pins the contract for every other caller: a supplied
+    // key is judged, and only an absent one probes unauthenticated. '' means
+    // "I have a key" and is answered as the empty key it is.
+    await expect(discoverModels({
+      baseURL: 'https://acme.test',
+      api: 'openai-completions',
+      apiKey: '',
+    })).rejects.toMatchObject({ code: 'INVALID_CREDENTIAL' })
+  })
+
+  it('leaves a probe with no key unauthenticated', async () => {
+    // The file's other cases capture headers through a real local HTTP server
+    // (`listingServer`); this one has no route or stored key to resolve, so
+    // the smallest real double is a `fetch` stub, scoped to this test and
+    // unstubbed by the shared `afterEach` above.
+    const requests: RequestInit[] = []
+    vi.stubGlobal('fetch', async (_url: string | URL, init?: RequestInit) => {
+      requests.push(init ?? {})
+      return new Response(JSON.stringify({ data: [] }), {
+        status: 200,
+        headers: { 'content-type': 'application/json' },
+      })
+    })
+
+    await discoverModels({ baseURL: 'https://acme.test', api: 'openai-completions' })
+
+    const headers = new Headers(requests[0]?.headers)
+    expect(headers.has('authorization')).toBe(false)
+  })
+})

+ 2 - 2
packages/llm/llm/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm/README.md
-README.md: ca34ffdeaafdbe061e030c80997b7234ce36a1bd
-README.zh.md: 1f95d3cd641126e129f94fe31269454a1bcce972
+README.md: 618d5f9f7c69c3ff2b420ae3fec96604802bf1be
+README.zh.md: 4b99c477eae694d1315d90920e835fcfde3b571a

+ 5 - 0
packages/llm/llm/README.md

@@ -63,6 +63,10 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
 
 Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
 
+### API key validation (`api-key.ts`)
+
+Every adapter that puts a credential in an HTTP header judges it the same way before use. `normalizeApiKey(raw)` trims surrounding whitespace, then accepts any non-empty printable-ASCII value (`/^[\x21-\x7E]+$/`, space excluded) or reports why not as an `ApiKeyRejection` (`'empty'` | `'illegalCharacters'`), both carried in the `ApiKeyCheck` result. Absence is never judged: a caller decides whether a value was supplied before asking, since a profile naming no credential authenticates through the provider's own ambient discovery or OAuth.
+
 ### Classes
 
 - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
@@ -73,6 +77,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
 - `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
 - `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits.
 - `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default.
+- `INVALID_CREDENTIAL_CODE` — the provider-neutral code for a credential that was supplied but cannot be used: malformed rather than absent, so the fix is to correct the stored value rather than supply one — the distinction from `MISSING_CREDENTIAL`. Deliberately excluded from the default retryable set, since a malformed credential fails identically on every attempt. `assertUsableApiKey(raw, pkg, ref)` throws `LlmError` with this code, the one shared diagnosis every adapter uses for an unusable stored credential.
 
 ### Real adapters
 

+ 5 - 0
packages/llm/llm/README.zh.md

@@ -63,6 +63,10 @@
 
 每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。
 
+### API 密钥校验(`api-key.ts`)
+
+每个要把凭据放进 HTTP 标头的适配器,使用前都以同一套规则校验它。`normalizeApiKey(raw)` 先去除首尾空白,再接受任意非空的可打印 ASCII 值(`/^[\x21-\x7E]+$/`,不含空格),否则以 `ApiKeyRejection`(`'empty'` | `'illegalCharacters'`)说明拒绝原因,二者一并包含在 `ApiKeyCheck` 结果中。缺失从不参与校验:调用方会在询问之前自行判断是否提供了值——未点名凭据的 profile 会转由提供方自身的环境发现或 OAuth 完成认证。
+
 ### 类
 
 - `LlmAdapter`:提供方适配器的抽象基类。唯一必需方法是 `stream()`。
@@ -73,6 +77,7 @@
 - `CONTEXT_WINDOW_EXCEEDED_CODE`:当请求超过模型上下文窗口时,无论通过 HTTP 异常抛出还是带内 finish 交付,两个 DeepSeek 适配器都使用的提供方无关 code。`isContextWindowExceededError(detail)` 是它们针对 OpenAI 兼容提供方详细信息的共享保守分类器。
 - `QUOTA_EXCEEDED_CODE`:帐户配额、余额、点数、预算或用量限制耗尽时使用的非短暂提供方无关 code。`isQuotaExceededError(detail)` 使这些失败与请求速率限制保持区分。
 - `EMPTY_RESPONSE_CODE`:两个适配器都使用的提供方无关 code,用于表示退化的提供方生成结果:一个未携带任何内容块的终止 `stop`。它会被分类为错误 finish(而非成功空消息),因为尝试未产生持久内容;`dsh-llm-retry` 默认重试它。
+- `INVALID_CREDENTIAL_CODE`:已提供但无法使用的凭据所用的提供方无关 code——格式错误而非缺失,修复方式是改正已存储的值而非补供一个,这正是它与 `MISSING_CREDENTIAL` 的区别。它被刻意排除在默认可重试集合之外:格式错误的凭据每次尝试都会以同样方式失败。`assertUsableApiKey(raw, pkg, ref)` 会以该 code 抛出 `LlmError`,是每个适配器判定已存储凭据不可用时共用的诊断。
 
 ### 真实适配器
 

+ 41 - 0
packages/llm/llm/src/api-key.ts

@@ -0,0 +1,41 @@
+/**
+ * The one definition of a well-formed provider API key, shared by every
+ * adapter that puts one in an HTTP header.
+ * @module @deepseek-ai/dsh-llm/api-key
+ */
+
+/**
+ * Characters an HTTP header value carries verbatim and every known provider
+ * key uses: printable ASCII, space excluded. A key outside this set cannot
+ * reach any provider — `fetch` refuses to build the header — so this is a
+ * transport invariant rather than one provider's policy. Latin-1 is excluded
+ * deliberately: a header could carry it, but no provider issues it, and
+ * admitting it trades a local explained refusal for an opaque 401.
+ */
+const LEGAL_API_KEY = /^[\x21-\x7E]+$/
+
+/** Why a supplied API key cannot be used. */
+export type ApiKeyRejection = 'empty' | 'illegalCharacters'
+
+/** The verdict on one supplied API key. */
+export type ApiKeyCheck =
+  | { readonly ok: true; readonly value: string }
+  | { readonly ok: false; readonly reason: ApiKeyRejection }
+
+/**
+ * Judge one *supplied* API key, trimming surrounding whitespace first.
+ *
+ * Trimming is silent because a padded key has one unambiguous reading; every
+ * other defect is reported. Absence is a configuration state this function
+ * never sees — a profile naming no credential authenticates through the
+ * provider's own ambient discovery or OAuth — so callers decide whether a
+ * value was supplied before asking.
+ * @param raw - the key exactly as configured, stored, or typed.
+ * @returns the trimmed key, or why it cannot be used.
+ */
+export function normalizeApiKey(raw: string): ApiKeyCheck {
+  const value = raw.trim()
+  if (value.length === 0) return { ok: false, reason: 'empty' }
+  if (!LEGAL_API_KEY.test(value)) return { ok: false, reason: 'illegalCharacters' }
+  return { ok: true, value }
+}

+ 9 - 0
packages/llm/llm/src/error.ts

@@ -38,6 +38,15 @@ export const QUOTA_EXCEEDED_CODE = 'QUOTA'
  */
 export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE'
 
+/**
+ * Canonical provider-neutral code for a credential that was supplied but
+ * cannot be used — malformed rather than absent. Distinct from
+ * `MISSING_CREDENTIAL` because the fix differs: correct the stored value
+ * rather than supply one. Deliberately outside the default retryable set —
+ * a malformed credential fails identically on every attempt.
+ */
+export const INVALID_CREDENTIAL_CODE = 'INVALID_CREDENTIAL'
+
 /** Structured codes and plain phrases that explicitly name a context bound being exceeded. */
 const STRUCTURED_CONTEXT_OVERFLOW = new RegExp(
   String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]`

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

@@ -25,13 +25,15 @@ import type { ResolvedRetryPolicy } from './retry-policy.ts'
 import type { ProviderRequestId } from './brand.ts'
 import { callConfigEquals, deepFreeze } from './call-config.ts'
 import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'
-import { HarnessError } from './error.ts'
+import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts'
 import { normalizeLlmFailure } from './adapter-failure.ts'
+import { normalizeApiKey } from './api-key.ts'
 
 export * from './attribution.ts'
 export * from './brand.ts'
 export * from './never.ts'
 export * from './error.ts'
+export * from './api-key.ts'
 export * from './types.ts'
 export * from './message.ts'
 export * from './retry-policy.ts'
@@ -122,6 +124,41 @@ export class LlmError extends HarnessError {
   }
 }
 
+/**
+ * Accept one supplied credential, or refuse it as unusable.
+ *
+ * A stored key arrives from the credentials seam, a `.env` line, or a shell
+ * export, all of which pick up surrounding whitespace, so trimming is silent.
+ * Anything else fails here rather than inside `fetch`, whose ByteString
+ * refusal names a UTF-16 code point instead of the setting to change. The key
+ * never enters the message: `ref` names where to fix it, and echoing any part
+ * of a secret into a log or a UI is the failure this diagnosis avoids.
+ *
+ * Lives beside {@link LlmError} rather than in `./api-key.ts` so the predicate
+ * module stays dependency-free; both adapters share this one diagnosis instead
+ * of keeping near-identical local copies.
+ * @param raw - the credential exactly as supplied.
+ * @param pkg - the refusing package name, prefixed to the diagnostic.
+ * @param ref - the credential reference the value resolved through.
+ * @returns the trimmed, usable key.
+ */
+export function assertUsableApiKey(raw: string, pkg: string, ref: string): string {
+  const checked = normalizeApiKey(raw)
+  if (checked.ok) return checked.value
+  // The Models page is named as the writer it usually is, not as the only one:
+  // the same value can arrive from a hand-edited .env or a shell export in a
+  // composition that mounts no credentials seam at all, where directing the
+  // user to a page that deployment does not serve would be a dead end.
+  throw new LlmError(
+    checked.reason === 'empty'
+      ? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key`
+        + ' (the web Models page writes it) or export it in the launching environment'
+      : `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry;`
+        + ` set ${ref} to the raw key alone (the web Models page writes it)`,
+    INVALID_CREDENTIAL_CODE,
+  )
+}
+
 /** One model call whose config and adapter registration were resolved together. */
 export interface PreparedLlmCall {
   /** Detached, deep-frozen config with any adapter-owned default materialized. */

+ 70 - 0
packages/llm/llm/tests/api-key.spec.ts

@@ -0,0 +1,70 @@
+import { describe, expect, it } from 'vitest'
+import { assertUsableApiKey, INVALID_CREDENTIAL_CODE, normalizeApiKey } from '@deepseek-ai/dsh-llm'
+
+describe('normalizeApiKey', () => {
+  it('accepts a printable-ASCII key unchanged', () => {
+    expect(normalizeApiKey('sk-0123456789abcdef')).toEqual({ ok: true, value: 'sk-0123456789abcdef' })
+  })
+
+  it('trims surrounding whitespace before judging', () => {
+    expect(normalizeApiKey('  sk-abc\t\n')).toEqual({ ok: true, value: 'sk-abc' })
+  })
+
+  it.each([
+    ['an empty string', ''],
+    ['spaces only', '   '],
+    ['a tab only', '\t'],
+  ])('rejects %s as empty', (_label, raw) => {
+    expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'empty' })
+  })
+
+  it.each([
+    ['an emoji', 'sk-\u{1F600}abc'],
+    ['CJK text', 'sk-你好'],
+    ['full-width punctuation', 'sk-abc,'],
+    ['an interior space', 'sk-abc def'],
+    ['a C0 control character', 'sk-abc\x01'],
+    ['a latin-1 character', 'sk-café'],
+  ])('rejects %s as illegal characters', (_label, raw) => {
+    expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'illegalCharacters' })
+  })
+
+  it('accepts the printable-ASCII boundary characters', () => {
+    expect(normalizeApiKey('!~')).toEqual({ ok: true, value: '!~' })
+  })
+
+  it('publishes a code distinct from a missing credential', () => {
+    expect(INVALID_CREDENTIAL_CODE).toBe('INVALID_CREDENTIAL')
+  })
+})
+
+describe('assertUsableApiKey', () => {
+  it('returns the trimmed key when it is usable', () => {
+    expect(assertUsableApiKey('  sk-abc  ', 'llm-deepseek', 'DEEPSEEK_API_KEY')).toBe('sk-abc')
+  })
+
+  it('refuses a blank stored credential, naming the reference', () => {
+    expect(() => assertUsableApiKey('   ', 'llm-deepseek', 'DEEPSEEK_API_KEY'))
+      .toThrow(/llm-deepseek: the API key resolved from DEEPSEEK_API_KEY is blank/)
+  })
+
+  it('refuses an unusable stored credential with the invalid-credential code', () => {
+    try {
+      assertUsableApiKey('sk-\u{1F600}', 'llm-pi-ai', 'ACME_API_KEY')
+      expect.fail('an illegal key must throw')
+    } catch (error) {
+      expect((error as { code: string }).code).toBe(INVALID_CREDENTIAL_CODE)
+      expect((error as Error).message).toContain('llm-pi-ai')
+      expect((error as Error).message).toContain('ACME_API_KEY')
+    }
+  })
+
+  it('never echoes the key it refuses', () => {
+    try {
+      assertUsableApiKey('sk-\u{1F600}supersecret', 'llm-deepseek', 'DEEPSEEK_API_KEY')
+      expect.fail('an illegal key must throw')
+    } catch (error) {
+      expect((error as Error).message).not.toContain('supersecret')
+    }
+  })
+})

Algúns arquivos non se mostraron porque demasiados arquivos cambiaron neste cambio