Merge pull request #1827 from deepseek-harness/worktree/charming-swartz-83bf33
fix(llm,web): validate API key format before it reaches an HTTP header
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: 9d1fbdddd1ad9ec4c073dd1c0ca4ac7124c1b876
|
||||
README.zh.md: ff740bc6d1096901cbcc33772aff09deafeb53a4
|
||||
README.md: 80ae642ec9d6f91c78af041dda0b201959309577
|
||||
README.zh.md: 4236c8fec4f6d5e51363095d790944af9c08092a
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 新生的路由都无需轮询即可收敛。
|
||||
|
||||
## 模型列表与端点询问
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
import {
|
||||
DeepSeekModelsEditor, modelDrafts, validateDeepSeekModels,
|
||||
} from './DeepSeekModelsEditor.tsx'
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
@@ -168,15 +169,26 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
|
||||
const stringAt = (source: unknown, key: string): string | undefined => {
|
||||
const value = getPath(source, [key])
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value : undefined
|
||||
}
|
||||
const setField = (key: string, next: string | undefined): void => {
|
||||
setDraft(current => next === undefined ? deletePath(current, [key]) : setPath(current, [key], next))
|
||||
// A value of nothing but whitespace is cleared, not stored: `stringAt`
|
||||
// already reports it as absent, so the field would otherwise render empty
|
||||
// while the draft still carried the spaces into `settings.yaml`, where
|
||||
// both adapters would accept that non-empty string as a real value.
|
||||
const value = next === undefined || next.trim().length === 0 ? undefined : next
|
||||
setDraft(current => value === undefined ? deletePath(current, [key]) : setPath(current, [key], value))
|
||||
}
|
||||
|
||||
// The model list is validated by the same per-row checker for both families,
|
||||
// so a bad row is named by its position rather than by a blanket message.
|
||||
const modelFailure = validateDeepSeekModels(getPath(draft, ['models']))
|
||||
const keyFailure = apiKeyFailure(keyDraft)
|
||||
// What a probe or a write must carry: the typed key with paste whitespace
|
||||
// removed. A blank field yields an empty string, which both call sites read
|
||||
// as "no key supplied" rather than as a key — that is how a card whose
|
||||
// provider already has a stored key is edited without re-entering it.
|
||||
const keyValue = keyDraft.trim()
|
||||
// What the form currently shows, which is what an interrogation must ask:
|
||||
// an edited-but-unsaved endpoint, and a key typed but not yet stored.
|
||||
const probeApi = stringAt(draft, 'api') ?? stringAt(fallback, 'api')
|
||||
@@ -188,7 +200,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
provider: props.provider,
|
||||
...probeBaseURL === undefined ? {} : { baseURL: probeBaseURL },
|
||||
...probeApi === undefined ? {} : { api: probeApi },
|
||||
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
|
||||
...keyValue.length === 0 ? {} : { apiKey: keyValue },
|
||||
}
|
||||
/**
|
||||
* The write for this card, or a failure message. Every edit travels as
|
||||
@@ -199,11 +211,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
*/
|
||||
const applyOnce = async (): Promise<string | undefined> => {
|
||||
const ns = namespace.ns
|
||||
const normalizedKey = keyDraft.trim()
|
||||
// A pi-ai profile names the conventional reference only when this page is
|
||||
// about to store a key. Otherwise the provider keeps its native auth path.
|
||||
const next = layout === 'pi-ai' && stringAt(draft, 'apiKeyEnv') === undefined
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && normalizedKey.length > 0
|
||||
&& stringAt(fallback, 'apiKeyEnv') === undefined && keyValue.length > 0
|
||||
? setPath(draft, ['apiKeyEnv'], keyRef)
|
||||
: draft
|
||||
{
|
||||
@@ -240,8 +251,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
setExpectedRevision(response.result.value.revision)
|
||||
setDraft(next)
|
||||
}
|
||||
if (normalizedKey.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: normalizedKey })
|
||||
if (keyValue.length > 0) {
|
||||
const stored = await api.credentials.set({ ref: keyRef, value: keyValue })
|
||||
if (!stored.result.ok) return stored.result.error.message
|
||||
}
|
||||
setKeyDraft('')
|
||||
@@ -330,6 +341,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
disabled={disabled || keyLocked}
|
||||
onChange={(event) => { setKeyDraft(event.target.value) }}
|
||||
/>
|
||||
{keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
|
||||
</div>
|
||||
<details className={styles['customized']}>
|
||||
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
|
||||
@@ -380,7 +392,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined}
|
||||
/>
|
||||
)
|
||||
: <ModelListEditor {...catalogProps} probe={probe} api={api} />}
|
||||
: <ModelListEditor {...catalogProps} probe={probe} probeBlocked={keyFailure} api={api} />}
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
@@ -413,7 +425,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<EditorFooter
|
||||
t={t}
|
||||
busy={busy}
|
||||
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined}
|
||||
submitDisabled={disabled || layout === 'unknown' || modelFailure !== undefined
|
||||
|| keyFailure !== undefined}
|
||||
submitLabel="apply"
|
||||
submitBusyLabel="applying"
|
||||
onCancel={() => { props.onClose(false) }}
|
||||
|
||||
58
packages/client/ui-models/src/client/apiKey.ts
Normal file
58
packages/client/ui-models/src/client/apiKey.ts
Normal 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
|
||||
}
|
||||
@@ -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: '显示名称不能为空。',
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user