Validate API key format before it reaches an HTTP header
在 API Key 进入 HTTP header 之前校验其格式
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 h
English
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, 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 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, so a key of three spaces was stored and then authenticated as Bearer plus blanks. Neither adapter checked a credential- or environment-sourced key — the path the Models page writes, and therefore the path users actually take.
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 reported input: 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 failures share one definition rather than 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
The rule applies to a value that was provided; deciding whether one was provided at all stays with each caller.
No named credential. A pi-ai profile omitting apiKeyEnv may authenticate outside the harness-held credential path. routeAuth in 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. namesCredential carries this distinction; omission is not a value to validate.
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.
A resolved value that is whitespace-only. This is invalid at both adapters because it cannot authenticate a request. In the browser it is also a field-level failure: 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 Service Definition, beside 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 Service Definition'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-settings-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 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 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-settings-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 validation module shared by client and host. Rejected by the source-plane layout: client packages reference only client packages plus vendor/cordis and runtime-diagnostics/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 Service Definition, so that package 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 original complaint — 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. Matching any upper-case identifier followed by = would be broader than intended: an all-upper-case base64 key ending in padding (ABCD==) would match 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 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 the discovery probe, including that a probe with no key sends no authorization header.
packages/client/ui-settings-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.
中文
问题
一个含有 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 的 try 内部构造 Bearer header,而那个 catch 把一切失败都标为 TRANSPORT;该标签又在 DEFAULT_RETRYABLE_CODES 之中,于是一个永久且确定的故障还会被重试三次。
同样的输入在 llm-pi-ai 上更糟。它的探测路径在 discovery.ts 里用裸 fetch 构造同一个 header,并把一切失败包装成 could not reach <url>,于是一个本地的 Key 故障被报成网络不可达。这条探测在保存之前就够得着:ProviderEditor 把用户输入的 keyDraft 直接放进探测请求,所以「获取模型列表」按钮会在任何东西落盘之前就把非法 Key 发出去。
空白字符能通过每一道检查。ProviderEditor 判的是 keyDraft.length,于是三个空格构成的 Key 会被存下,随后以 Bearer 加若干空格去认证。两个适配器都不检查来自凭据或环境的 Key——而那正是 Models 页写入的路径,也就是用户真正走的路径。
决策
一条规则定义什么是合法 Key:trim 之后非空,且每个字符都落在 [\x21-\x7E]——可打印 ASCII,不含空格。
这一个断言覆盖了所有已报告的输入:空值、首尾空白、中间空白、C0 控制字符、emoji、中日韩文字、全角标点。它同时正是造成 ByteString 失败的那条约束,所以这些故障收敛于同一个定义,而不是两个恰好相关的修复。
第二条更窄的规则用于识别整行粘贴的环境变量:匹配 ^[A-Z][A-Z0-9_]*=[^=] 或首尾成对引号的输入会被拒绝。把前缀限定为全大写可以让真实 Key 与之绝缘——sk- 这类形态会在连字符处中断标识符匹配——而要求分隔符之后必须是非 = 字符,则让 base64 的 padding 也与之绝缘。它报出的是与非法字符相同的那条格式失败,而不是自己的一句:读到它的人下一步动作完全一样,因此单列一句只会点出一个原因,却不改变该怎么做。
不变量属于每一层,启发式属于人所在的那一层
字符集规则是不变量。非 ASCII 字符对任何提供方都不可能在 header value 中传输,因此在浏览器、在各个 resolver、在每一次凭据读取上执行它,是结构上的一致而非约定上的一致。
形状规则是对人如何粘贴的猜测,因此只在浏览器中运行。llm-pi-ai 前面挂着 OpenAI、Anthropic 以及任意手工声明的网关,本仓库并不掌握它们的 Key 格式;若这条规则运行在 resolver 中,一个签发形如 TENANT1=abc 的网关会让用户被彻底锁死、无路可走——设置页拒绝它,手写的 .env 在读取时同样被拒。把启发式限制在粘贴动作发生的那一层,环境变量便始终是那条出路。
「没有 Key」是一种配置状态,不是缺失
规则作用于已提供的值;至于究竟有没有提供,由各个调用方自行判断。
未点名凭据。 省略 apiKeyEnv 的 pi-ai profile 可以在 harness 持有的凭据路径之外鉴权。provider.ts 中的 routeAuth 保留内置 catalog 提供方自身的鉴权,正是为了让提供方原生的 ambient 发现继续工作;而该 catalog 附带的 openai-codex 通过 OAuth 鉴权。namesCredential 承载这一区分;省略不是需要校验的值。
Web UI 中留空的输入框。 即便某个提供方的 Key 已经存好,该输入框也是空着打开的——keyStored 的文案写的是「已配置——输入新值以替换」——所以留空意味着保持已存储的值。ProviderEditor 在草稿为空时完全跳过 credentials.set,这一点保持不变:留空绝不拦截提交,否则改一个 base URL 都得重新输一遍 Key。
解析得到的值只含空白。 两个适配器都将其视为非法,因为它无法为请求鉴权。在浏览器中,这同样是字段级失败:字段是人刚刚敲过字的地方,静默丢弃他敲进去的内容永远不是正确答案。
因此 normalizeApiKey 接受 string,而绝非 string | undefined。
规则住在哪里
normalizeApiKey 是 dsh-llm Service Definition 的一个模块,与已经承担共享 header 事务的 attribution.ts 并列。两个适配器都依赖该 seam 且都需要这条规则,因此它拥有两个当前消费方而非一个预设消费方。它返回 trim 后的值,或一个原因(empty、illegalCharacters)。
两个适配器同样都需要那句完全相同的「拒绝一个已存储凭据」的诊断,差别仅在包名前缀。LlmError 声明在 Service Definition 的 index.ts 中,因此 assertUsableApiKey(raw, pkg, ref) 就住在它旁边,两个适配器都不再各留一份。断言模块本身保持零依赖:把 LlmError 引入 api-key.ts 会与 index.ts 对它的再导出成环。
客户端无法引入其中任何一个:client 包只 reference client 包,因此 packages/client/ui-settings-models 在自己的 apiKey.ts 中镜像这个断言并持有本地化文案,正如 validateDeepSeekModels 镜像 host 侧的 catalogModel schema。两侧在注释中互相指名。
各处分别做什么
| 位置 | 行为 |
|---|---|
dsh-llm | 拥有 normalizeApiKey、assertUsableApiKey 与 INVALID_CREDENTIAL_CODE,后者刻意不进 DEFAULT_RETRYABLE_CODES。 |
llm-deepseek resolveApiKey | 归一化凭据 seam 或环境返回的值,以 INVALID_CREDENTIAL 拒绝,消息指明模型设置页,绝不回显 Key。 |
llm-pi-ai resolveApiKey | 归一化凭据与环境路径。不指定任何凭据的 profile 仍返回 undefined,ambient 与 OAuth 路由不受影响。 |
llm-pi-ai discoverModels | 在构造 header 之前归一化,使非法 Key 成为凭据故障而非端点不可达。不带 Key 的探测保持未鉴权。 |
ui-settings-models | 镜像字符集规则,加入形状启发式,在探测与 credentials.set 之前 trim keyDraft,并修正 stringAt 的空值判断。留空的输入框仍是可以提交的空操作;只含空白的输入框则是字段级失败。提交与端点探测同时受拦截,因此被拒绝的密钥不会白花一次往返去换取字段上已经写明的答案;失败呈现在字段上,与既有的 modelFailure 模式一致。 |
ProviderEditor 同时服务 DeepSeek 与 pi-ai 两种布局,因此一处客户端改动覆盖两个提供方。CustomProviderCard 为手工声明的路由承载同一套判定。
credentials-local 刻意不动。它存储各类凭据,而可打印 ASCII 是 HTTP header 的约束而非凭据存储的约束;它既有的、拒绝任何 dotenv 样式都无法表示的值的行为保持原样。
曾考虑的替代方案
由 client 与 host 共享一个校验模块。 被 source plane 布局否决:client 包只 reference client 包外加 vendor/cordis 与 runtime-diagnostics/invariants,把它放宽到够得着 host 包会撞上这一分割本就要隔开的两份 Context 合并。在两侧各镜像一行断言并各配一份测试,是此处的既定形态。
在 llm-deepseek 与 llm-pi-ai 中各留一个抛错 helper。 最初的计划正是各留一份,差别仅在消息中的包名前缀,并配一个重复检测豁免来放行这一对。在实现之前即被否决:LlmError 声明在 Service Definition 中,因此该包完全可以自己拥有这句诊断,而那里的一个豁免恰恰会掩盖它本要遮掩的重复。
在适配器的 catch 中嗅探 TypeError。 这只是事后归类 ByteString 失败,header 构造本身仍无防护。它依赖 Node 错误消息的措辞,因而会随运行时版本静默失效;它也帮不到 llm-pi-ai——后者的请求 header 构造在 pi-ai SDK 内部。在交出 Key 之前就拒绝,则对两个适配器与探测路径同时有效。
在 credentials-local.set 中执行。 它能一次性拦住所有写入方,包括手工编辑的文件。它落败于该提供方存储各种类型的凭据,而一条源自 HTTP header 编码的规则并不属于它。
让形状启发式也在 resolver 中运行。 更对称,且能拦住直接写进 .env 的整行环境变量。因上文所述的锁死风险而否决:resolver 中的一次误判会让用户无路可走,浏览器中的一次误判则仍留有环境变量这条路。
在保存时探测提供方以证明 Key 可用。 它能关掉最初报告的那件事——保存报成功、首个轮次才失败。因超出范围而否决,且在当时的代码上无法建成:对 pi-ai 恰好自带 catalog 的那些提供方,discoverModels 会在任何网络调用之前短路到内置 catalog,因而对 Key 什么都验证不了;而 DeepSeek 卡片根本没有探测。验证器的价值在于分清「Key 被拒」与「无法连通」,而这正是本次改动让其变得可靠的区分;先建验证器只会得到一个分不清自身结果的验证器。同类产品也不在保存时验证,因此保存时的阻断式网络调用会是一个意外行为,而非一处缺失。
后果
格式错误的 Key 在持有它的那个字段上就被拒绝;格式错误的已存储 Key 以 INVALID_CREDENTIAL 失败,消息指明修复位置且不含 Key 的任何片段。由于该 code 位于 DEFAULT_RETRYABLE_CODES 之外,一个确定性的凭据故障不再被当作瞬时传输抖动重试三次。llm-pi-ai 的探测把非法 Key 报为凭据故障,而非端点不可达。
形状启发式可能拒绝一个真实的 Key。匹配任意「全大写标识符接 =」会比预期覆盖面更宽:一个以 padding 结尾的全大写 base64 Key(ABCD==)会命中它并不像的赋值形态。要求分隔符之后必须是非 = 字符即可排除 padding——base64 的 padding 只出现在末尾。剩下的形态(大写名称、一个 =、然后是值)是已知提供方不会签发的,且该规则只在浏览器中运行,因此仍撞上它的用户可通过环境变量设置该凭据。残留代价是对一个尚无人报告过的 Key 给出一次令人困惑的拒绝。
限定为可打印 ASCII 比传输本身的要求更严:header value 是可以承载 \x80–\xFF 的。放行 latin-1 会让 é 通过并换回一个语焉不详的 401,而不是一次本地的、有解释的拒绝,因此从严是刻意的。若某个提供方签发 latin-1 的 Key,这条规则需要放宽。
字符集断言存在两份,每个 source plane 一份。布局禁止共享它;两侧各自带测试并在注释中指名其孪生体。
早先版本已存下的 Key 会经 resolveApiKey 读取,因此一个非法的既存值将从解析时开始失败,而非到请求时才失败。诊断变好了,但对当前正持有这类值的人而言,失败点提前了。
把这件事做错的最大代价,会是把「未指定」当成「非法」:一条施加到 undefined 上的规则会打断每一条依赖 ambient 发现或 OAuth 鉴权的路由,而一个会拦截提交的空输入框,则会让改动任何其他设置都必须重新输入 Key。这两点都由测试钉住,而不是仅仰赖谨慎。
测试
packages/llm/llm/tests/api-key.spec.ts 以整张输入表驱动 normalizeApiKey 与 assertUsableApiKey——空值、纯空白、带首尾空白、含中间空格、C0 控制字符、emoji、中日韩文字、全角、latin-1,以及可打印 ASCII 的边界字符——并钉住一次拒绝携带 INVALID_CREDENTIAL 且不含 Key 的任何部分。
packages/llm/llm-deepseek/tests/ 在 dynamic-config.spec.ts 中经真实凭据 seam(而非 stub)端到端覆盖已存储凭据路径。packages/llm/llm-pi-ai/tests/ 覆盖探测路径,包括不带 Key 的探测不会发出 authorization 标头。
packages/client/ui-settings-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 只覆盖了浏览器那一半。