feat(web): refuse an unusable API key on the field that holds it

This commit is contained in:
Yichen Jiang
2026-08-06 22:30:43 +08:00
parent 665b5697bb
commit cf9eade39d
6 changed files with 233 additions and 8 deletions
@@ -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,8 +81,14 @@ 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.
@@ -112,8 +119,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 +215,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
disabled={disabled}
onChange={(event) => { setKeyDraft(event.target.value) }}
/>
{keyFailure === undefined ? null : <p className={styles['error']}>{t(keyFailure)}</p>}
</div>
<ModelListEditor
models={models}
@@ -216,7 +224,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
settingsNs: NS,
baseURL,
api: protocol,
...keyDraft.length === 0 ? {} : { apiKey: keyDraft },
...keyValue.length === 0 ? {} : { apiKey: keyValue },
}}
api={api}
t={t}
@@ -22,6 +22,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'
@@ -163,7 +164,7 @@ 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))
@@ -172,6 +173,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
// 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')
@@ -183,7 +190,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
@@ -226,8 +233,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
: 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 })
if (!stored.result.ok) return stored.result.error.message
}
setKeyDraft('')
@@ -313,6 +320,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>
@@ -396,7 +404,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) }}
@@ -0,0 +1,50 @@
/**
* 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. Restricted to an upper-case
* identifier so a real key cannot match: `sk-` forms break at the hyphen.
* 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. */
export type ApiKeyFailureKey = 'keyBlank' | 'keyIllegalCharacters' | 'keyLooksWrapped'
/** 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 'keyLooksWrapped'
if (!LEGAL_API_KEY.test(value)) return 'keyIllegalCharacters'
return undefined
}
@@ -46,6 +46,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.',
keyIllegalCharacters: 'This API key contains characters that cannot be sent. Paste the raw key only.',
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.',
@@ -130,6 +133,9 @@ export const zh: typeof en = {
addModel: '添加模型',
removeModel: '删除模型',
modelsEmpty: '模型选择器中将不显示任何模型;目录外 ID 仍可直接发送。',
keyBlank: '请输入 API 密钥;留空则保持已存储的密钥。',
keyIllegalCharacters: '该 API 密钥含有无法发送的字符。请只粘贴原始密钥。',
keyLooksWrapped: '请只粘贴密钥本身——不要带 NAME=value 整行,也不要带引号。',
modelIdRequired: '模型 ID 不能为空。',
modelIdDuplicate: '模型 ID 不能重复。',
modelNameInvalid: '显示名称不能为空。',