diff --git a/packages/client/ui-models/src/client/CustomProviderCard.tsx b/packages/client/ui-models/src/client/CustomProviderCard.tsx index b4c655472a..a252d99586 100644 --- a/packages/client/ui-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-models/src/client/CustomProviderCard.tsx @@ -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 :

{t(keyFailure)}

} { 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 :

{t(keyFailure)}

}
{t('customized')} @@ -396,7 +404,8 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { { props.onClose(false) }} diff --git a/packages/client/ui-models/src/client/apiKey.ts b/packages/client/ui-models/src/client/apiKey.ts new file mode 100644 index 0000000000..a9d5bb3d32 --- /dev/null +++ b/packages/client/ui-models/src/client/apiKey.ts @@ -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 +} diff --git a/packages/client/ui-models/src/client/locales.ts b/packages/client/ui-models/src/client/locales.ts index 19463d98fa..fbfc85c7f1 100644 --- a/packages/client/ui-models/src/client/locales.ts +++ b/packages/client/ui-models/src/client/locales.ts @@ -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: '显示名称不能为空。', diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index aa9082e7dd..d9034ecd44 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -11,6 +11,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' @@ -1080,3 +1081,52 @@ 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'], + ])('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 wrapped', (_label, draft) => { + expect(apiKeyFailure(draft)).toBe('keyLooksWrapped') + }) + + 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() + }) +}) diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index 99e85b0d10..a167710153 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -862,4 +862,106 @@ describe('hand-declared providers', () => { await waitFor(() => { expect(screen.queryByText(en.customTitle)).toBeNull() }) 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('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('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.keyLooksWrapped)).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('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' }) + }) })