fix(web): report a wrapped paste as the same API key format failure
This commit is contained in:
@@ -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: e3328bb5fd2cf812b05dc26bf534226818132631
|
||||
README.zh.md: 20e40cc571a9123b50dfb28565c5562937e03189
|
||||
README.md: a9799916a91cb7416765c387b59c9172fa477666
|
||||
README.zh.md: e73100fa7d23a4025dffe471615401617a4af811
|
||||
|
||||
@@ -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. 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 field holding only whitespace fails rather than being silently dropped, and a value shaped like a pasted `NAME=value` environment line or wrapped in matching quotes fails too; 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. 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 write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
Every edit lands as `settings.mutate` path ops against the stored section — a set per changed field, an unset per cleared one, and a single unset for a deleted provider row. The page only ever holds the REDACTED descriptor, so it names the fields it can see rather than rebuilding a section: a stored literal secret it never received is mentioned by no op and survives. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. A row carries the model id and display name; its context window and output cap sit behind the row's own disclosure, the same shape the pi-ai provider form uses. Either capacity is typed as a count with an optional decimal `K` or `M` suffix (`256K`, `1M`; `1M` is 1000K) and stored as the plain count, spelled back in the shortest form that round-trips. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. 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 field holding only whitespace fails rather than being silently dropped, and 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. 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 write carries the `revision` the card opened at, so a concurrent write from another tab or an external `settings.yaml` edit is refused as `settings-conflict` and the card asks the user to reopen instead of replaying its stale snapshot. The page refetches on the pushed invalidations (`settings/changed`, `credentials/changed`, `models/changed`, and `connection/reset`) once it has loaded, so an external `settings.yaml` edit, a second tab, or a settings-born route converges without polling.
|
||||
|
||||
## Model list and endpoint interrogation
|
||||
|
||||
|
||||
@@ -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、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值也会失败——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
每一次编辑都以 `settings.mutate` 的路径 op 落到已存分节上——每个变更字段一条 set、每个清空字段一条 unset、删除提供方行则是单独一条 unset。页面自始至终只持有**脱敏后**的 descriptor,因此它点名自己看得见的字段,而不是重建分节:一个它从未收到过的已存字面机密不会被任何 op 提及,也就得以留存。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器会显示继承而来的生效模型行,直到第一次模型编辑将完整数组具化到用户层;重置则会取消该覆盖。每个模型行承载模型 ID 与显示名称,其上下文窗口与最大输出 token 数则收在该行自己的折叠区里,与 pi-ai 提供方表单采用的形态相同。两项容量都按数值键入,可带十进制的 `K` 或 `M` 后缀(`256K`、`1M`;`1M` 即 1000K),存储为纯数值,回显时写成能够往返的最短形式。空 ID、重复 ID、显式填写的空名称,以及无法读取、非正数或非整数的容量都会在写入前失败。键入的 API 密钥同样在它自己的字段上被判定:trim 之后必须非空,且每个字符都是可打印 ASCII(`[\x21-\x7E]`)——这正是 HTTP 标头值所能承载的范围,是 `@deepseek-ai/dsh-llm` 中 `normalizeApiKey` 的孪生体,因源码平面分割禁止直接引入而在此镜像。只含空白的输入框会失败,而不是被静默丢弃;形如整行粘贴的 `NAME=value` 环境变量或首尾成对引号包裹的值,会以同一条格式失败被拒绝——该粘贴形状启发式只在浏览器中运行,因为 resolver 中的一次误判会连带让环境变量这条路也拒绝该密钥。留空则完全不是失败:在编辑卡片上它意味着保持已存储的密钥,在新建卡片上则意味着以其他方式鉴权。被拒绝的密钥会同时拦截写入与端点探测,因此页面不会白花一次往返去换取字段上已经写明的答案。每次写入都携带该卡片打开时的 `revision`,因此来自另一个标签页或对 `settings.yaml` 的外部编辑所产生的并发写入会以 `settings-conflict` 被拒绝,卡片会请用户重新打开,而不是把自己的陈旧快照重放上去。页面加载完成后会在推送的失效事件(`settings/changed`、`credentials/changed`、`models/changed` 与 `connection/reset`)上重拉,因此外部的 `settings.yaml` 编辑、第二个标签页或 settings 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型列表与端点询问
|
||||
|
||||
|
||||
@@ -22,8 +22,14 @@ const LEGAL_API_KEY = /^[\x21-\x7E]+$/
|
||||
*/
|
||||
const ENV_LINE = /^[A-Z][A-Z0-9_]*=[^=]/
|
||||
|
||||
/** Copy key naming why a typed key cannot be saved. */
|
||||
export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped'
|
||||
/**
|
||||
* 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 {
|
||||
@@ -46,7 +52,7 @@ 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 'keyLooksWrapped'
|
||||
if (ENV_LINE.test(value) || isQuoted(value)) return 'keyIllegalCharacters'
|
||||
if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters'
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -49,7 +49,6 @@ export const en = {
|
||||
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.',
|
||||
keyLooksWrapped: 'Paste only the key itself — not a NAME=value line, and without surrounding quotes.',
|
||||
modelIdRequired: 'Model ID is required.',
|
||||
modelIdDuplicate: 'Model ID must be unique.',
|
||||
modelNameInvalid: 'Display name cannot be empty.',
|
||||
@@ -137,7 +136,6 @@ export const zh: typeof en = {
|
||||
keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
|
||||
keyBlankNew: '请输入 API 密钥;若该提供方以其他方式鉴权,可以留空。',
|
||||
keyIllegalCharacters: '该 API 密钥格式错误,请检查。',
|
||||
keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。',
|
||||
modelIdRequired: '模型 ID 不能为空。',
|
||||
modelIdDuplicate: '模型 ID 不能重复。',
|
||||
modelNameInvalid: '显示名称不能为空。',
|
||||
|
||||
@@ -1121,8 +1121,8 @@ describe('apiKeyFailure', () => {
|
||||
['double quotes', '"sk-abc"'],
|
||||
['single quotes', '\'sk-abc\''],
|
||||
['backticks', '`sk-abc`'],
|
||||
])('fails %s as wrapped', (_label, draft) => {
|
||||
expect(apiKeyFailure(draft)).toBe('keyLooksWrapped')
|
||||
])('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', () => {
|
||||
|
||||
@@ -987,7 +987,7 @@ describe('API key field', () => {
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.keyInput), { target: { value: 'OPENAI_API_KEY=sk-abc' } })
|
||||
|
||||
expect(screen.getByText(en.keyLooksWrapped)).toBeTruthy()
|
||||
expect(screen.getByText(en.keyIllegalCharacters)).toBeTruthy()
|
||||
expect(buttonNamed(en.apply).disabled).toBe(true)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user