Merge branch 'task/command-feedback-master' into codex/otel-feedback-levels

This commit is contained in:
Turtle
2026-08-07 15:28:12 +08:00
61 changed files with 1564 additions and 74 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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#1595dsh-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 只覆盖了浏览器那一半。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-13-documentation-site-projection.md
2026-07-13-documentation-site-projection.md: 2452c9dfa53e05061446df2fe650f3b4d6428c01
2026-07-13-documentation-site-projection.zh.md: 6f1c79ac502a04714cd77f680108dbff035b048c
2026-07-13-documentation-site-projection.md: f19d9b309aa22821a75086dc07ee302097631ba0
2026-07-13-documentation-site-projection.zh.md: cc5e94e709f0639fd35ad81165b199cc5c9effc0

View File

@@ -18,7 +18,7 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g
Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching.
The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image becomes a raw GitHub URL. Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates.
The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a GitHub source link; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates.
`website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest.

View File

@@ -18,7 +18,7 @@ Status: implemented
各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。
投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会变成 GitHub raw URL。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。
投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 GitHub 源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md)。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。
`website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。

View File

@@ -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/process/2026-08-06-doc-site-carries-its-images.md
2026-08-06-doc-site-carries-its-images.md: 9109808874579b79d85c2e22b0987110f41ddc42
2026-08-06-doc-site-carries-its-images.zh.md: d601112e8870150c363d8533e85ef86e7f3f8ffc

View File

@@ -0,0 +1,43 @@
# Agent Note: The documentation site carries its own images
Status: implemented
English | [中文](2026-08-06-doc-site-carries-its-images.zh.md)
## Problem
`scripts/project-doc-site.ts` rewrote every repository-relative target that the publication manifest does not publish into a GitHub URL, and for an image that meant `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`. Nothing in the site build copies files: `srcDir` is the disposable `.generated` tree, VitePress sets no `publicDir` (its default, `<srcDir>/public`, is inside the tree the projector deletes on every run), and only Markdown is written there.
That works only for a public repository. This one is private, and `raw.githubusercontent.com` answers 404 to an unauthenticated request — a browser session on github.com does not authenticate it either, since GitHub's own UI serves private blobs through separately signed URLs. Every image on the site was therefore broken for every reader, and no gate said so: `verify-md-links` and the projector check that the target file *exists in the repository*, which is a different question from whether a site reader can fetch it.
## Decision
`rewriteMarkdown` takes an optional `placeImage(absPath): string`. When a page references an image the manifest does not publish as a page, the projector copies that file into the generated tree beside the page and rewrites the reference to `./<basename>`; Vite then bundles it like any other site asset. Nothing about repository visibility can reach the published page.
The copy lands beside the page rather than in a shared asset directory. Each locale's route tree gets its own copy, so one relative URL is correct from both `guide/` and `en/guide/` without computing per-locale prefixes, and a page's assets are removed with the page when the manifest drops it. One map claims every projected path — pages and images alike — so a second source for one path throws, in the same spirit as the existing duplicate-route check, rather than letting whichever wrote last win.
Only a regular file whose real path stays inside the repository is copied; anything else fails the projection naming the page and the target. Link rewriting needs to know a target *exists*, but publication copies its bytes onto the site, so a reference escaping the repository — through `../..` or a symlink out of the tree — would put a build-machine file on a published page. The reference's `?query` or `#fragment` rides along to the placed URL exactly as the GitHub branch has always carried it, and the file name is percent-encoded because the destination is a Markdown inline target.
`docsSourceFiles()` reports the placed images alongside the Markdown, so the dev server's watcher re-projects when a screenshot is replaced instead of serving the previous copy until something touches the page.
`placeImage` is optional because `rewriteMarkdown` is also called directly by its spec, where no generated tree exists. Without it the old GitHub-raw behavior stands, which keeps that seam honest: the fallback is still the correct answer for a consumer that only rewrites text.
Canonical Markdown keeps writing ordinary repository-relative image paths, so the same file renders on GitHub and on the site. No document carries a site-absolute URL to satisfy VitePress.
## Alternatives considered
**Set `publicDir` outside `.generated` and reference site-absolute URLs.** Fewer moving parts in the projector, but every image reference would then be broken when the same Markdown is read in the repository, and canonical docs are read both ways.
**Host images on the assets branch, as demo GIFs already are.** That branch exists to keep large binaries out of the main history, and its raw URLs have exactly the same visibility problem. It remains the right home for recordings; it does not solve this.
**Wait for the repository to become public.** It would fix the symptom without making the site self-contained, and the site would silently depend on GitHub's availability and rate limits for every image.
## Consequences
Images in published documentation now work regardless of who is reading or whether the repository is public, and the site build has no runtime dependency on GitHub for them. The generated tree grows by one copy of each referenced image per locale — the four screenshots in the model-provider guide add roughly 270 KB per locale.
Images referenced from *unpublished* documents are untouched: they still resolve to GitHub raw, and still fail for a private repository. Nothing consumes them today, and a document that is not on the site has no site build to carry its assets.
## Testing
`scripts/project-doc-site.spec.ts` covers the placer receiving the resolved absolute path and the returned URL landing in the Markdown, a placed reference keeping its fragment, a published page link still resolving to its route when a placer is present, and the unchanged GitHub-raw fallback when no placer is supplied. `publishableImage` is covered directly: a regular file inside the repository resolves, while a symlink whose target escapes it, a path outside it, and a directory are all refused. `pnpm docs:check` builds the site with the model-provider guide's screenshots and fails on a missing source; the copied files and their `./<basename>` references were verified in `website/.generated` and in a running `docs:dev` (`naturalWidth > 0` in both locales).

View File

@@ -0,0 +1,43 @@
# Agent Note: 文档站点自带图片
Status: implemented
[English](2026-08-06-doc-site-carries-its-images.md) | 中文
## Problem
`scripts/project-doc-site.ts` 会把发布清单未收录的仓库相对目标一律改写成 GitHub 地址,对图片而言就是 `https://raw.githubusercontent.com/<owner>/<repo>/<ref>/<path>`。站点构建不拷贝任何文件:`srcDir` 是用完即弃的 `.generated`VitePress 没有设置 `publicDir`(其默认值 `<srcDir>/public` 恰好位于投影每次运行时删除的那棵树里),而写进去的只有 Markdown。
这只对公开仓库成立。本仓库是私有的,而 `raw.githubusercontent.com` 对未认证请求一律回 404——github.com 上的登录会话也不能认证它,因为 GitHub 自家界面是用另一套单独签名的地址提供私有 blob 的。于是站点上的每一张图片对每一位读者都是坏的,却没有任何门禁能说出来:`verify-md-links` 与投影校验的是目标文件**在仓库里是否存在**,那与站点读者能否取到它是两个问题。
## Decision
`rewriteMarkdown` 新增可选的 `placeImage(absPath): string`。当页面引用了一张清单未作为页面发布的图片时,投影把该文件复制进生成树中该页面的旁边,并把引用改写为 `./<basename>`;随后 Vite 会像处理其他站点资源一样打包它。仓库可见性再也影响不到已发布页面。
副本落在页面旁边,而不是某个共享资源目录。每个 locale 的路由树各持一份副本,因此同一个相对 URL 在 `guide/``en/guide/` 下都正确,无需按 locale 计算前缀;清单撤下某页时,它的资源也随之消失。一张表登记所有被投影的路径——页面与图片一视同仁——同一路径出现第二个来源就抛错,与既有的重复路由检查同一个立场,而不是让最后写入的那个静默胜出。
只有真实路径位于仓库内的普通文件才会被拷贝,其余一律让投影失败并点名页面与目标。链接改写只需要知道目标**存在**,但发布是把它的字节拷上站点,因此一个逃出仓库的引用——经由 `../..` 或指向树外的符号链接——会把构建机上的文件放到已发布页面上。引用自带的 `?query``#fragment` 会随安置后的 URL 一同保留,与 GitHub 分支一贯的做法一致;文件名做百分号编码,因为目标位于 Markdown 内联目标的位置。
`docsSourceFiles()` 会连同被安置的图片一起上报,于是替换截图时开发服务器的 watcher 会重新投影,而不是一直服务旧副本直到有人碰一下页面。
`placeImage` 之所以可选,是因为 `rewriteMarkdown` 也被它自己的 spec 直接调用,而那里并不存在生成树。不传它时保持原有的 GitHub raw 行为,这也让该接缝保持诚实:对只改写文本的消费方而言,这个回退仍是正确答案。
正本 Markdown 照旧写普通的仓库相对图片路径,因此同一份文件在 GitHub 上和站点上都能正常显示。没有任何文档为了迁就 VitePress 而写站内绝对 URL。
## Alternatives considered
**把 `publicDir` 设到 `.generated` 之外,并使用站内绝对 URL。** 投影这边的活动部件更少,但同一份 Markdown 在仓库中阅读时,每一处图片引用都会是坏的,而正本文档是两种方式都要读的。
**把图片放到 assets 分支,就像演示 GIF 那样。** 那个分支的存在是为了让大体积二进制不进主线历史,而它的 raw 地址有着完全相同的可见性问题。它仍然是录屏的正确归宿;但它解决不了这件事。
**等仓库转为公开。** 那只是消除症状,不会让站点自给自足,而且每一张图片都会让站点隐式依赖 GitHub 的可用性与限流。
## Consequences
已发布文档中的图片,现在无论谁在阅读、无论仓库是否公开都能显示,站点构建也不再为图片依赖 GitHub 的运行时可达性。生成树会为每个 locale 各增加一份被引用图片的副本——配置模型指南里的四张截图,每个 locale 约 270 KB。
**未发布**文档引用的图片不受影响:它们仍解析到 GitHub raw对私有仓库仍然失败。今天没有任何消费方用到它们而不在站点上的文档也没有站点构建可以承载其资源。
## Testing
`scripts/project-doc-site.spec.ts` 覆盖placer 收到解析后的绝对路径且其返回的 URL 落进 Markdown、被安置的引用保留其 fragment、存在 placer 时已发布页面的链接仍解析到自己的路由、以及不传 placer 时不变的 GitHub raw 回退。`publishableImage` 另有直接覆盖:仓库内的普通文件被接受,而目标逃出仓库的符号链接、仓库外的路径与目录一律拒绝。`pnpm docs:check` 会带着配置模型指南的截图构建站点,并在来源缺失时失败;被拷贝的文件及其 `./<basename>` 引用已在 `website/.generated` 与运行中的 `docs:dev` 里核实(两个 locale 均 `naturalWidth > 0`)。

View File

@@ -46,6 +46,7 @@ Write normal repository-relative Markdown links in canonical docs. The projector
- A target present in the manifest becomes a site-relative route.
- An existing target outside the manifest becomes a GitHub source link, including supported line suffixes.
- An image is the exception: its file is copied into the generated tree and referenced from there, so the site serves it regardless of repository visibility. It must be a regular file inside the repository.
- External URLs, site-absolute URLs, email links, and fragment-only links remain unchanged.
- A missing repository-relative target fails projection instead of silently producing a broken link.

View File

@@ -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: '设置' })

View File

@@ -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`

View File

@@ -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/*`

View File

@@ -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`

View File

@@ -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-telemetry-otel`](../packages/telemetry/session-telemetry-otel), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) |

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -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 docs/user/guide/providers.md
providers.md: 66b6cf25c61a252fbd10a85f8c79c246eeae8abe
providers.zh.md: a2c33c90be971e09ab29e2355ca6a7ae6f947c39

View File

@@ -0,0 +1,120 @@
# Configure models
English | [中文](providers.zh.md)
Harness ships with DeepSeek and mounts a generic multi-provider adapter alongside it, for the providers in pi-ai's installed catalog — Anthropic, OpenAI, and the rest — and for any OpenAI-compatible gateway or self-hosted server. You have two entry points: the **Models** page in the web UI, and `$DSH_HOME/settings.yaml`. Both write the same document, and a change takes effect on the next request without a restart.
## Where providers come from
`cordis.yml` decides which **adapters** are installed; the settings document decides which **providers** run. The shipped composition carries two LLM adapters:
- `llm-deepseek` serves the `deepseek-official` route, the one available out of the box.
- `llm-pi-ai` mounts **dormant**: zero routes and no extra entries in the model picker until an `llm-pi-ai:` settings section supplies provider profiles, at which point those routes register live and drop again when the section empties.
Adding a provider therefore rarely means editing `cordis.yml` — writing settings is enough, and that is exactly what the Models page does.
## Configure from the web UI
Start `pnpm run dsh web` and open **Settings → Models**.
![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png)
**Give DeepSeek its key.** The DeepSeek card carries one API-key field; fill it in, save, and the provider is ready.
**Add a provider from the installed catalog.** Choose **Add provider**, pick one of pi-ai's catalog providers (anthropic, openai, and so on), and enter that provider's API key. The endpoint, protocol, and model catalog all come from the catalog; the key is the only thing you owe.
That holds for providers that authenticate with an API key. The catalog also carries Bedrock, Vertex, Azure, and Codex, which need AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively: filling in the key field alone will not make them work. Those authenticate through pi-ai's own environment discovery, with credentials prepared the way each one requires.
**Add a custom provider.** Choose **Add a custom provider** for a route the catalog does not ship — a company gateway, a self-hosted server, or a provider newer than the installed catalog. It asks for a Provider ID (the lowercase identifier that names the route in requests and as its credential), a base URL, a protocol, and at least one model.
![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png)
**Let the endpoint report its models.** Expand **Model catalog** and choose **Fetch available models**: the interrogation asks the endpoint **the form currently shows** — including a base URL edited but not yet saved and a key typed but not yet stored — and offers what it reports as candidates to pick from. A route the installed catalog describes is answered from that catalog with no network call. Adopting a candidate only writes rows into the draft; nothing is stored until you save.
Keys are write-only: the page only ever holds a redacted descriptor, never the literal secret. A key you enter is stored in `$DSH_HOME/.env`, and the profile records only the variable name that references it.
## settings.yaml for advanced configuration
The document lives at `$DSH_HOME/settings.yaml` (`$DSH_HOME` defaults to `~/.dsh`). The Models page writes this file, and you can edit it directly; neither source outranks the other.
```yaml
llm-deepseek:
reasoningEffort: high
llm-pi-ai:
providers:
# Catalog route: endpoint, protocol, and models come from pi-ai; you supply
# the credential.
openai:
apiKeyEnv: OPENAI_API_KEY
# Also a catalog route, moved to a private proxy, with its catalog narrowed
# to one model and that model's capacity corrected. Every unset field still
# comes from the catalog.
anthropic:
apiKeyEnv: ANTHROPIC_API_KEY
baseURL: https://proxy.example.com:8443
reasoning: high
models:
- id: claude-sonnet-4-5
contextWindow: 200000
# Hand-declared route: pi-ai ships nothing under this key, so the profile
# supplies the whole provider.
acme-gateway:
displayName: Acme Gateway
apiKeyEnv: ACME_GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.acme.example/v1
models:
- id: acme-large
name: Acme Large
contextWindow: 65536
maxTokens: 4096
```
A settings section merges over the matching `cordis.yml` configuration **per provider**, so you can override one field of one route and leave the rest as the composition set them.
A profile the adapter could not serve is refused **where it is written**: a hand-declared route needs `api`, `baseURL`, and at least one model, and a profile missing any of them fails naming the offending route and model rather than being stored and quietly disabling the whole namespace. When an already-stored document is broken by an external edit, settings keeps the last good value and warns.
## The model catalog
A profile's `models` list *replaces* that route's installed catalog rather than extending it; omitting it or leaving it empty serves the catalog unchanged. Each entry defaults its unset fields from the installed model of the same `id`, so narrowing a route to two models, correcting one capacity, or adding a model newer than the installed catalog are each a one-line edit.
Only the four fields the harness consumes are configurable: `id`, `name`, `contextWindow`, and `maxTokens`. Pricing and input modalities have no consumer, and reasoning is not per-model configurable at all — it rides the installed catalog entry.
A model neither the entry nor the catalog sizes takes the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields: a deployment whose gateway serves smaller models corrects them once.
Model ids are not lifecycle configuration. Requesting a model the route does not configure fails with `UNKNOWN_MODEL` before any provider request goes out.
## Credentials
Prefer `apiKeyEnv`: it is a *reference* resolved per request, so no secret enters the configuration file. A literal `apiKey` is the escape hatch. Omitting both is what leaves a route unauthenticated, which for a catalog route means pi-ai's own environment discovery. A reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` rather than falling through to whatever unrelated key the environment happens to hold.
References resolve from `$DSH_HOME/.env` — what the Models page's key fields write — and from the matching environment variable when no credential service is mounted. One credential serves every model on its route.
## Point an agent at the new provider
A configured route appears in the web model picker and can be switched at any time, which is how most people use it.
A new session's default model comes from the `api-gateway` entry (`@deepseek-ai/dsh-host-apiproxy`) and its `provider` and `model`, which ship as `deepseek-official` and `deepseek-v4-flash`. To change that default, override the entry in `$DSH_HOME/config.yaml`:
```yaml
- id: api-gateway
config:
provider: acme-gateway
model: acme-large
```
A patch replaces that entry's whole `config`, so write out every key it needs to keep. A composition you assemble yourself — headless, for instance — sets `agent-loop`'s `agents` instead.
## Troubleshooting
- **`MISSING_CREDENTIAL`** — the variable the profile's `apiKeyEnv` names holds no value. Store the key once through the Models page, or export the variable.
- **`UNKNOWN_MODEL`** — the requested model is not in the route's configured catalog. Add it to `models`, or use an id the catalog already carries.
- **`settings-rejected`** — the written profile cannot be served, and the message names the route and model. For a hand-declared route, check that `api`, `baseURL`, and `models` are all present.
- **Fetching available models answers 401** — the endpoint refused the interrogation. Check the key; if the base URL points at an Anthropic-style gateway, note that the interrogation reads only the OpenAI-compatible `GET /models`, so enter the models by hand instead.
## Exact field reference
The complete fields, types, and defaults each plugin currently supports live in the generated [plugin configuration catalog](../../config-catalog.md). Each adapter's own semantics belong to its README: [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md). For `cordis.yml` itself, see [Configuration](./config.md).

View File

@@ -0,0 +1,120 @@
# 配置模型
[English](providers.md) | 中文
Harness 出厂就带 DeepSeek同时挂着一个通用的多提供方适配器用来接入 Anthropic、OpenAI 这类内置目录里的提供方,或任何 OpenAI 兼容的网关与自建服务。你有两个入口Web 界面的**模型**页,以及 `$DSH_HOME/settings.yaml`。两者写的是同一份文档,改完下一次请求即生效,不用重启。
## 提供方从哪里来
`cordis.yml` 决定装了哪些**适配器**settings 文档决定跑哪些**提供方**。出厂组合里有两个 LLM 适配器:
- `llm-deepseek` 提供 `deepseek-official` 路由,是默认可用的那个。
- `llm-pi-ai` 以**休眠**状态挂载:零路由,模型选择器里也不会多出条目,直到 settings 里的 `llm-pi-ai:` 段落给出 provider profile路由才注册上来段落清空则一并撤下。
因此新增一个提供方通常不需要改 `cordis.yml`,写 settings 就够了——而模型页做的正是这件事。
## 在 Web 界面里配置
启动 `pnpm run dsh web`,打开**设置 → 模型**。
![模型页DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png)
**填 DeepSeek 的密钥。** DeepSeek 卡片上只有一个 API 密钥输入框,填好保存即可开始用。
**添加内置目录里的提供方。** 点**添加提供方**,从 pi-ai 内置目录中选一个anthropic、openai 等),填入该提供方的 API 密钥。端点、协议和模型目录都由内置目录提供,你只需要给密钥。
只对以 API 密钥认证的提供方成立。目录里也有 Bedrock、Vertex、Azure、Codex它们分别需要 AWS 凭据与区域、ADC 项目配置、`api-version`、OAuth只填密钥框不会让它们工作——这类提供方靠 pi-ai 自己的环境发现认证,凭据按各自的原生方式准备。
**添加自定义提供方。** 点**添加自定义提供方**,用于内置目录没有的路由——公司网关、自建服务,或比内置目录更新的提供方。需要填 Provider ID请求里点名它、也作为凭据名的小写标识、API 地址、协议,以及至少一个模型。
![自定义提供方表单Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png)
**让端点自己报模型。** 展开**模型目录**后点**获取可用模型**,会按你**当前表单里**的地址与密钥去问端点(地址改了但没保存、密钥刚输入还没存下,都算数),把它报告的模型列成候选让你勾选。内置目录里的路由直接由目录作答,不联网。采纳只是把行写进草稿,最终还是你点保存才落盘。
密钥是只写的:页面拿到的永远是脱敏描述符,不是明文。写入的密钥存进 `$DSH_HOME/.env`profile 里只记录引用它的变量名。
## settings.yaml进阶配置
文档位于 `$DSH_HOME/settings.yaml``$DSH_HOME` 默认是 `~/.dsh`)。模型页写的就是这个文件,你也可以直接编辑它——两个来源没有主次之分。
```yaml
llm-deepseek:
reasoningEffort: high
llm-pi-ai:
providers:
# Catalog route: endpoint, protocol, and models come from pi-ai; you supply
# the credential.
openai:
apiKeyEnv: OPENAI_API_KEY
# Also a catalog route, moved to a private proxy, with its catalog narrowed
# to one model and that model's capacity corrected. Every unset field still
# comes from the catalog.
anthropic:
apiKeyEnv: ANTHROPIC_API_KEY
baseURL: https://proxy.example.com:8443
reasoning: high
models:
- id: claude-sonnet-4-5
contextWindow: 200000
# Hand-declared route: pi-ai ships nothing under this key, so the profile
# supplies the whole provider.
acme-gateway:
displayName: Acme Gateway
apiKeyEnv: ACME_GATEWAY_API_KEY
api: openai-completions
baseURL: https://gateway.acme.example/v1
models:
- id: acme-large
name: Acme Large
contextWindow: 65536
maxTokens: 4096
```
settings 段落**逐个提供方**地盖在 `cordis.yml` 的同名配置之上,所以你可以只覆盖某个路由的一个字段,其余保持组合里的样子。
一份服务不了的 profile 会在**写入处**被拒绝:手工声明的路由必须给出 `api``baseURL` 和至少一个模型缺了会带着路由名和模型名报错而不是存下来再让整个命名空间静默失效。已经存好的文档被外部改坏时settings 会保留上一次的好值并告警。
## 模型目录
`models` 是**替换**该路由的内置目录,不是往里追加;省略或留空则原样使用内置目录。每个条目会从同 `id` 的内置模型继承自己没写的字段,所以「收窄到两个模型」「更正一个容量」「加一个比内置目录更新的模型」都是一行编辑。
可配置的只有 harness 会消费的四个字段:`id``name``contextWindow``maxTokens`。定价与输入模态没有消费方,推理能力也不按模型配置——它随内置目录条目走。
两处容量都没给出的模型,取路由级兜底 `defaultContextWindow`262144`defaultMaxTokens`32768。这两个数按定义就是猜测所以它们是路由字段网关服务的模型更小时改一次即可。
模型 id 不是生命周期配置:请求一个该路由没有配置的模型,会在任何网络请求之前以 `UNKNOWN_MODEL` 失败。
## 凭据
优先用 `apiKeyEnv`——它是一个**引用**,每次请求时解析,密钥本身不进配置文件;`apiKey` 字面量是应急出口。两者都不给,才表示这个路由不带认证,对内置目录路由意味着交给 pi-ai 自己的环境发现。给了引用却解析不到,请求会以 `MISSING_CREDENTIAL` 失败,而不是退回去用环境里碰巧存在的某个不相干的 key 计费。
引用解析自 `$DSH_HOME/.env`(模型页的密钥输入框写的就是它),没有挂载凭据服务时则直接读同名环境变量。一份凭据供该路由上的所有模型使用。
## 让 agent 用上新提供方
配好的路由会出现在 Web 的模型选择器里,随时可切,这也是最常用的方式。
新会话的默认模型来自 `api-gateway` 那条(`@deepseek-ai/dsh-host-apiproxy`)的 `provider``model`,出厂值是 `deepseek-official``deepseek-v4-flash`。要改默认值,就在 `$DSH_HOME/config.yaml` 里覆盖该条:
```yaml
- id: api-gateway
config:
provider: acme-gateway
model: acme-large
```
补丁会整体替换该条的 `config`,所以要把这条需要保留的键一并写出。自行组装的 `cordis.yml`(例如 headless改的则是 `agent-loop``agents`
## 排错
- **`MISSING_CREDENTIAL`** — profile 里的 `apiKeyEnv` 指向的变量没有值。用模型页存一次密钥,或导出该环境变量。
- **`UNKNOWN_MODEL`** — 请求的模型不在该路由配置的目录里。把它加进 `models`,或改用目录里已有的 id。
- **`settings-rejected`** — 写入的 profile 服务不了,错误信息会点名具体的路由和模型。手工声明的路由检查 `api``baseURL``models` 是否齐全。
- **获取可用模型返回 401** — 端点拒绝了这次探测。检查密钥;若地址指向的是 Anthropic 风格网关,注意探测只读 OpenAI 兼容的 `GET /models`,此时手工填写模型即可。
## 精确字段参考
每个插件当前支持的完整字段、类型与默认值见自动生成的[插件配置目录](../../config-catalog.md)。两个适配器各自的语义由它们的 README 负责:[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) 与 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md)。`cordis.yml` 本身的写法见[配置文件](./config.md)。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md
quickstart.md: 4b43291342f80ff9c6dcb844fdc505ef797d1f6c
quickstart.zh.md: 13294c50c2d7d4005c0fd090cc2eca3c4780d0a6
quickstart.md: 72cc5a52c33faf81098f747799692b384fcd5f1a
quickstart.zh.md: ebb831c1e1bec02117c78a4c2426a84af3bb9478

View File

@@ -57,5 +57,6 @@ headless-agent uses the `@deepseek-ai/dsh-cli-demo` app. `dsh web` instead boots
## Next steps
- [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways
- [Configuration](./config.md) — understand the `cordis.yml` format
- [Develop a plugin](../develop/basic/) — build your own tool or backend

View File

@@ -57,5 +57,6 @@ headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app。`dsh web` 则启动 `web
## 下一步
- [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关
- [配置文件](./config.md) — 了解 `cordis.yml` 的格式
- [开发插件](../develop/basic/) — 编写自己的 tool 或后端

View File

@@ -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',

View File

@@ -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":""}

View File

@@ -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

View File

@@ -8,7 +8,7 @@ Rows are the *configured* providers (their profile resolves in the owning namesp
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured literal `apiKey` secret sidecar or configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A typed API key is judged on its own field the same way: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure; that paste-shape heuristic runs only in the browser, since a false positive in a resolver would leave the environment refusing the key as well. A field holding only whitespace fails rather than being silently dropped, while an empty field is not a failure at all: it means keep the stored key on an editor card, and authenticate some other way on a create card. A refused key blocks both the write and the endpoint interrogation, so the page never spends a round trip to be told what the field already says. Each settings write carries the card's current `revision`, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict`; after settings commit, the card adopts the returned redacted user subtree and revision before storing the credential, which makes a failed credential stage retry only that stage. Deletion removes a configured, writable credential only when the profile names the page's derived `<ROUTE>_API_KEY` target, then unsets the profile; both operations are idempotent, and a partial failure remains in the identified confirmation dialog for retry. Environment credentials, custom references, and credentials whose target cannot be identified remain untouched. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
## Model list and endpoint interrogation

View File

@@ -8,7 +8,7 @@
前序首次使用引导页面完成后DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。若 `apiKey` 字面量对应的 secret 槽位标记为已设置或凭据引用已配置该步骤会直接完成而不渲染其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置凭据能力不可用时该步骤均不渲染并直接完成以免首次使用引导阻塞产品Models 页仍是诊断界面。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor因此它点名自己看得见的字段而不是重建分节一个它从未收到过的已存字面机密不会被任何 op 提及也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K``M` 后缀(`256K``1M``1M` 即 1000K存储为纯数值回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定trim 之后必须非空,且每个字符都是可打印 ASCII`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm``normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝;该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。只含空白的输入框会失败而不是被静默丢弃;留空则完全不是失败:在编辑卡片上意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次 settings 写入都携带卡片当前的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝settings 提交成功后,卡片会在存储凭据前采用响应返回的脱敏用户子树与 revision因此凭据阶段失败时重试只会重复该阶段。删除操作只会在 profile 指向页面派生的 `<ROUTE>_API_KEY` 目标时清除已配置且可写的凭据,随后取消设置 profile两项操作都具备幂等性部分失败会停留在点名目标的确认对话框中供重试。环境凭据、自定义引用和无法识别目标的凭据保持不变。页面加载完成后会在推送的失效事件`settings/changed``credentials/changed``models/changed``connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
## 模型列表与端点询问

View File

@@ -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}

View File

@@ -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')}

View File

@@ -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) }}

View File

@@ -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
}

View File

@@ -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: '显示名称不能为空。',

View File

@@ -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()
})
})

View File

@@ -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')

View File

@@ -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

View File

@@ -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.

View File

@@ -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')` 始终报告当前策略。

View File

@@ -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`

View File

@@ -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')
}
})
})

View File

@@ -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' })

View File

@@ -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

View File

@@ -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.

View File

@@ -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` 这类部署路径会保留其路径段。

View File

@@ -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) },

View File

@@ -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, {

View File

@@ -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,`

View File

@@ -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/)
})
})

View File

@@ -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)
})
})

View File

@@ -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

View File

@@ -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

View File

@@ -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`,是每个适配器判定已存储凭据不可用时共用的诊断。
### 真实适配器

View File

@@ -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 }
}

View File

@@ -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_-]`

View File

@@ -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. */

View File

@@ -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')
}
})
})

View File

@@ -1,12 +1,14 @@
/** Tests for the documentation website projection adapter. */
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { docsPages, type DocsPage } from '../website/docs.ts'
import { addProjectionFrontmatter, projectedPageContent, rewriteMarkdown } from './project-doc-site.ts'
import {
addProjectionFrontmatter, projectedPageContent, publishableImage, rewriteMarkdown,
} from './project-doc-site.ts'
const roots: string[] = []
const repositoryRoot = resolve(import.meta.dirname, '..')
@@ -63,6 +65,32 @@ describe('website source layout', () => {
})
})
describe('publishableImage', () => {
it('accepts a regular file inside the repository', () => {
const { root } = fixture()
const real = realpathSync(join(root, 'packages/logo.svg'))
expect(publishableImage(join(root, 'packages/logo.svg'), realpathSync(root))).toBe(real)
})
it('refuses a target whose real path escapes the repository', () => {
// Publication copies the bytes onto the site, so a reference reaching a
// build-machine file must not be treated as an image the repository owns.
const { root } = fixture()
const outside = mkdtempSync(join(tmpdir(), 'dsh-doc-site-outside-'))
roots.push(outside)
writeFileSync(join(outside, 'secret.png'), 'not really a png\n')
symlinkSync(join(outside, 'secret.png'), join(root, 'packages/linked.png'))
expect(publishableImage(join(root, 'packages/linked.png'), realpathSync(root))).toBeUndefined()
expect(publishableImage(join(outside, 'secret.png'), realpathSync(root))).toBeUndefined()
})
it('refuses a directory', () => {
const { root } = fixture()
expect(publishableImage(join(root, 'packages'), realpathSync(root))).toBeUndefined()
})
})
describe('rewriteMarkdown', () => {
it('maps published pages and pins unpublished source links', () => {
const { root, pages } = fixture()
@@ -93,7 +121,7 @@ describe('rewriteMarkdown', () => {
})).toBe('[B](./reference-root/b.md)\n')
})
it('uses raw GitHub content for unpublished images', () => {
it('uses raw GitHub content for unpublished images when nothing places them', () => {
const { root, pages } = fixture()
expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
locale: 'en',
@@ -105,6 +133,57 @@ describe('rewriteMarkdown', () => {
})).toBe('![logo](https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/abc123/packages/logo.svg)\n')
})
it('hands an image to the placer and uses the URL it returns', () => {
// A raw GitHub URL cannot serve a private repository, so the site build
// carries images itself; the placer is what puts them there. The stand-in
// derives its URL the way the real one does, so a placer that stopped
// returning the basename would fail here rather than pass on a constant.
const { root, pages } = fixture()
const placed: string[] = []
expect(rewriteMarkdown('![logo](../packages/logo.svg)\n', {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
placeImage: (absPath) => {
const name = absPath.split('/').pop() ?? ''
placed.push(name)
return `./${name}`
},
})).toBe('![logo](./logo.svg)\n')
expect(placed).toEqual(['logo.svg'])
})
it('keeps a placed image\u2019s query or fragment', () => {
// An SVG view fragment and a Vite query both change what the reference
// means, and the GitHub branch has always carried them.
const { root, pages } = fixture()
expect(rewriteMarkdown('![logo](../packages/logo.svg#view)\n', {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
placeImage: absPath => `./${absPath.split('/').pop() ?? ''}`,
})).toBe('![logo](./logo.svg#view)\n')
})
it('leaves a published page link to the route even when a placer exists', () => {
const { root, pages } = fixture()
expect(rewriteMarkdown('[B](b.md)\n', {
locale: 'en',
sourcePath: 'docs/a.md',
route: 'en/a.md',
pages,
repoRoot: root,
repositoryRef: 'abc123',
placeImage: () => { throw new Error('a page link must not be placed as an asset') },
})).toBe('[B](./reference/b.md)\n')
})
it('does not rewrite Markdown-looking text inside code fences', () => {
const { root, pages } = fixture()
const source = '```md\n[B](b.md)\n```\n'

View File

@@ -5,8 +5,10 @@
* tier, while this adapter rewrites cross-source links for the public site.
*/
import { existsSync, lstatSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { dirname, extname, posix, relative, resolve, sep } from 'node:path'
import {
copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync,
} from 'node:fs'
import { basename, dirname, extname, posix, relative, resolve, sep } from 'node:path'
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
@@ -38,6 +40,15 @@ export interface RewriteMarkdownOptions {
pages: DocsPage[]
repoRoot: string
repositoryRef: string
/**
* Place one referenced image beside the projected page and return the URL to
* reach it from that page. A GitHub raw URL cannot serve this repository —
* `raw.githubusercontent.com` answers 404 for a private one, and no reader of
* the site is authenticated to it — so an image travels into the generated
* tree and Vite bundles it like any other site asset. Omitted by callers that
* only rewrite text, which then leave images pointing at the repository.
*/
placeImage?: (absPath: string) => string
}
function repoPath(absPath: string, repoRoot: string): string {
@@ -222,9 +233,13 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
? options.locale === 'root' ? 'en' : 'root'
: options.locale
const page = published.get(targetPath)?.get(targetLocale)
const nextUrl = page === undefined
? githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
: routeTarget(options.route, page.route, suffix)
const nextUrl = page !== undefined
? routeTarget(options.route, page.route, suffix)
: node.type === 'image' && options.placeImage !== undefined
// The suffix rides along exactly as the GitHub branch keeps it: an SVG
// view fragment or a Vite query changes what the reference means.
? `${options.placeImage(absPath)}${suffix}`
: githubTarget(absPath, line, suffix, options.repositoryRef, options.repoRoot, node.type === 'image')
const start = node.position?.start.offset
const end = node.position?.end.offset
@@ -291,17 +306,78 @@ export function projectedPageContent(markdown: string, page: DocsPage): string {
return markdown.slice(0, closing + closingDelimiter.length)
}
/** Canonical Markdown files watched by the local VitePress dev server. */
/**
* The repository file one image reference resolves to, or `undefined` when the
* target is not a local file this build may publish.
* @param absPath - resolved image target.
* @param repoRoot - repository root every published image must stay inside.
* @returns the file's real path, or `undefined` when it must not be copied.
*
* Only a regular file whose real path stays inside the repository qualifies.
* Publication copies the bytes into the site, so a reference escaping the
* repository — `../../.ssh/id_rsa`, or a symlink pointing out of the tree —
* would put a build-machine file on the site; `existsSync` alone, which is all
* link resolution needs, does not answer that.
*/
export function publishableImage(absPath: string, repoRoot: string): string | undefined {
const real = realpathSync(absPath)
const inside = real === repoRoot || real.startsWith(`${repoRoot}${sep}`)
return inside && statSync(real).isFile() ? real : undefined
}
/** Every local image a published page references, resolved to its repository file. */
function referencedImages(): string[] {
const found = new Set<string>()
for (const page of docsPages) {
const sourceAbs = resolve(root, page.source)
if (!existsSync(sourceAbs)) continue
rewriteMarkdown(readFileSync(sourceAbs, 'utf8'), {
sourcePath: page.source,
locale: page.locale,
route: page.route,
pages: docsPages,
repoRoot: root,
repositoryRef: 'master',
placeImage: (absPath) => {
const real = publishableImage(absPath, root)
if (real !== undefined) found.add(real)
return ''
},
})
}
return [...found]
}
/**
* Files watched by the local VitePress dev server: every canonical Markdown
* source, plus the images they publish. Without the images, replacing a
* screenshot leaves the previous copy in the generated tree until something
* touches the Markdown beside it.
*/
export function docsSourceFiles(): string[] {
return [...new Set(docsPages.map(page => resolve(root, page.source)))]
return [...new Set([...docsPages.map(page => resolve(root, page.source)), ...referencedImages()])]
}
/** Rebuild the disposable VitePress source tree from the publication manifest. */
export function projectDocs(): void {
const routes = new Set<string>()
/** Projected path to the repository file that claimed it, pages and images alike. */
const claimed = new Map<string, string>()
const repositoryRef = process.env.GITHUB_SHA ?? 'master'
rmSync(generatedRoot, { recursive: true, force: true })
/** Reserve one projected path, refusing a second source for it. */
const claim = (target: string, sourceAbs: string): void => {
const holder = claimed.get(target)
if (holder !== undefined && holder !== sourceAbs) {
throw new Error(
`project-doc-site: ${repoPath(sourceAbs, root)} and ${repoPath(holder, root)}`
+ ` both project to ${relative(generatedRoot, target).split(sep).join('/')}.`,
)
}
claimed.set(target, sourceAbs)
}
for (const page of docsPages) {
if (routes.has(page.route)) throw new Error(`project-doc-site: duplicate route ${JSON.stringify(page.route)}.`)
routes.add(page.route)
@@ -310,6 +386,9 @@ export function projectDocs(): void {
throw new Error(`project-doc-site: source ${JSON.stringify(page.source)} does not exist or is not a file.`)
}
const output = resolve(generatedRoot, page.route)
// Claimed before the images are placed: a page and an image landing on one
// path would otherwise overwrite each other in whichever order they ran.
claim(output, sourceAbs)
mkdirSync(dirname(output), { recursive: true })
const markdown = readFileSync(sourceAbs, 'utf8')
const projected = rewriteMarkdown(markdown, {
@@ -319,6 +398,25 @@ export function projectDocs(): void {
pages: docsPages,
repoRoot: root,
repositoryRef,
placeImage: (absPath) => {
const real = publishableImage(absPath, root)
if (real === undefined) {
throw new Error(
`project-doc-site: ${page.source} references image ${repoPath(absPath, root)},`
+ ' which is not a regular file inside the repository.',
)
}
// Beside the page that references it, under its own basename: each
// locale's route tree gets its own copy, so one relative URL is correct
// from both.
const name = basename(real)
const target = resolve(dirname(output), name)
claim(target, real)
copyFileSync(real, target)
// Encoded because the destination is a Markdown inline target, where an
// unescaped space would end it early.
return `./${encodeURI(name)}`
},
})
writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page))
}

View File

@@ -130,13 +130,21 @@ const homeAndGuide = pairedPages([
section: { root: '入门', en: 'Guide' },
order: 2,
},
{
source: 'docs/user/guide/providers.md',
route: 'guide/providers.md',
label: { root: '配置模型', en: 'Configure models' },
sidebar: { root: 'zh-guide', en: 'en-guide' },
section: { root: '入门', en: 'Guide' },
order: 3,
},
{
source: 'docs/user/guide/config.md',
route: 'guide/config.md',
label: { root: '配置文件', en: 'Configuration' },
sidebar: { root: 'zh-guide', en: 'en-guide' },
section: { root: '入门', en: 'Guide' },
order: 3,
order: 4,
},
])