diff --git a/packages/llm/llm/src/api-key.ts b/packages/llm/llm/src/api-key.ts new file mode 100644 index 0000000000..85d0b1ed60 --- /dev/null +++ b/packages/llm/llm/src/api-key.ts @@ -0,0 +1,41 @@ +/** + * The one definition of a well-formed provider API key, shared by every + * adapter that puts one in an HTTP header. + * @module @deepseek-ai/dsh-llm/api-key + */ + +/** + * Characters an HTTP header value carries verbatim and every known provider + * key uses: printable ASCII, space excluded. A key outside this set cannot + * reach any provider — `fetch` refuses to build the header — so this is a + * transport invariant rather than one provider's policy. Latin-1 is excluded + * deliberately: a header could carry it, but no provider issues it, and + * admitting it trades a local explained refusal for an opaque 401. + */ +const LEGAL_API_KEY = /^[\x21-\x7E]+$/ + +/** Why a supplied API key cannot be used. */ +export type ApiKeyRejection = 'empty' | 'illegalCharacters' + +/** The verdict on one supplied API key. */ +export type ApiKeyCheck = + | { readonly ok: true; readonly value: string } + | { readonly ok: false; readonly reason: ApiKeyRejection } + +/** + * Judge one *supplied* API key, trimming surrounding whitespace first. + * + * Trimming is silent because a padded key has one unambiguous reading; every + * other defect is reported. Absence is a configuration state this function + * never sees — a profile naming no credential authenticates through the + * provider's own ambient discovery or OAuth — so callers decide whether a + * value was supplied before asking. + * @param raw - the key exactly as configured, stored, or typed. + * @returns the trimmed key, or why it cannot be used. + */ +export function normalizeApiKey(raw: string): ApiKeyCheck { + const value = raw.trim() + if (value.length === 0) return { ok: false, reason: 'empty' } + if (!LEGAL_API_KEY.test(value)) return { ok: false, reason: 'illegalCharacters' } + return { ok: true, value } +} diff --git a/packages/llm/llm/src/error.ts b/packages/llm/llm/src/error.ts index fbb8bccca5..9ff193f1f8 100644 --- a/packages/llm/llm/src/error.ts +++ b/packages/llm/llm/src/error.ts @@ -38,6 +38,15 @@ export const QUOTA_EXCEEDED_CODE = 'QUOTA' */ export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE' +/** + * Canonical provider-neutral code for a credential that was supplied but + * cannot be used — malformed rather than absent. Distinct from + * `MISSING_CREDENTIAL` because the fix differs: correct the stored value + * rather than supply one. Deliberately outside the default retryable set — + * a malformed credential fails identically on every attempt. + */ +export const INVALID_CREDENTIAL_CODE = 'INVALID_CREDENTIAL' + /** Structured codes and plain phrases that explicitly name a context bound being exceeded. */ const STRUCTURED_CONTEXT_OVERFLOW = new RegExp( String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]` diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 74ca171f64..287bfc2f34 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -25,13 +25,15 @@ import type { ResolvedRetryPolicy } from './retry-policy.ts' import type { ProviderRequestId } from './brand.ts' import { callConfigEquals, deepFreeze } from './call-config.ts' import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' -import { HarnessError } from './error.ts' +import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts' import { normalizeLlmFailure } from './adapter-failure.ts' +import { normalizeApiKey } from './api-key.ts' export * from './attribution.ts' export * from './brand.ts' export * from './never.ts' export * from './error.ts' +export * from './api-key.ts' export * from './types.ts' export * from './message.ts' export * from './retry-policy.ts' @@ -122,6 +124,36 @@ export class LlmError extends HarnessError { } } +/** + * Accept one supplied credential, or refuse it as unusable. + * + * A stored key arrives from the credentials seam, a `.env` line, or a shell + * export, all of which pick up surrounding whitespace, so trimming is silent. + * Anything else fails here rather than inside `fetch`, whose ByteString + * refusal names a UTF-16 code point instead of the setting to change. The key + * never enters the message: `ref` names where to fix it, and echoing any part + * of a secret into a log or a UI is the failure this diagnosis avoids. + * + * Lives beside {@link LlmError} rather than in `./api-key.ts` so the predicate + * module stays dependency-free; both adapters share this one diagnosis instead + * of keeping near-identical local copies. + * @param raw - the credential exactly as supplied. + * @param pkg - the refusing package name, prefixed to the diagnostic. + * @param ref - the credential reference the value resolved through. + * @returns the trimmed, usable key. + */ +export function assertUsableApiKey(raw: string, pkg: string, ref: string): string { + const checked = normalizeApiKey(raw) + if (checked.ok) return checked.value + throw new LlmError( + checked.reason === 'empty' + ? `${pkg}: the API key stored as ${ref} is blank; re-enter it on the web Models page` + : `${pkg}: the API key stored as ${ref} contains characters no HTTP header can carry;` + + ' re-enter it on the web Models page, pasting the raw key only', + INVALID_CREDENTIAL_CODE, + ) +} + /** One model call whose config and adapter registration were resolved together. */ export interface PreparedLlmCall { /** Detached, deep-frozen config with any adapter-owned default materialized. */ diff --git a/packages/llm/llm/tests/api-key.spec.ts b/packages/llm/llm/tests/api-key.spec.ts new file mode 100644 index 0000000000..a04a103fb9 --- /dev/null +++ b/packages/llm/llm/tests/api-key.spec.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { assertUsableApiKey, INVALID_CREDENTIAL_CODE, normalizeApiKey } from '@deepseek-ai/dsh-llm' + +describe('normalizeApiKey', () => { + it('accepts a printable-ASCII key unchanged', () => { + expect(normalizeApiKey('sk-0123456789abcdef')).toEqual({ ok: true, value: 'sk-0123456789abcdef' }) + }) + + it('trims surrounding whitespace before judging', () => { + expect(normalizeApiKey(' sk-abc\t\n')).toEqual({ ok: true, value: 'sk-abc' }) + }) + + it.each([ + ['an empty string', ''], + ['spaces only', ' '], + ['a tab only', '\t'], + ])('rejects %s as empty', (_label, raw) => { + expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'empty' }) + }) + + it.each([ + ['an emoji', 'sk-\u{1F600}abc'], + ['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é'], + ])('rejects %s as illegal characters', (_label, raw) => { + expect(normalizeApiKey(raw)).toEqual({ ok: false, reason: 'illegalCharacters' }) + }) + + it('accepts the printable-ASCII boundary characters', () => { + expect(normalizeApiKey('!~')).toEqual({ ok: true, value: '!~' }) + }) + + it('publishes a code distinct from a missing credential', () => { + expect(INVALID_CREDENTIAL_CODE).toBe('INVALID_CREDENTIAL') + }) +}) + +describe('assertUsableApiKey', () => { + it('returns the trimmed key when it is usable', () => { + expect(assertUsableApiKey(' sk-abc ', 'llm-deepseek', 'DEEPSEEK_API_KEY')).toBe('sk-abc') + }) + + it('refuses a blank stored credential, naming the reference', () => { + expect(() => assertUsableApiKey(' ', 'llm-deepseek', 'DEEPSEEK_API_KEY')) + .toThrow(/llm-deepseek: the API key stored as DEEPSEEK_API_KEY is blank/) + }) + + it('refuses an unusable stored credential with the invalid-credential code', () => { + try { + assertUsableApiKey('sk-\u{1F600}', 'llm-pi-ai', 'ACME_API_KEY') + expect.fail('an illegal key must throw') + } catch (error) { + expect((error as { code: string }).code).toBe(INVALID_CREDENTIAL_CODE) + expect((error as Error).message).toContain('llm-pi-ai') + expect((error as Error).message).toContain('ACME_API_KEY') + } + }) + + it('never echoes the key it refuses', () => { + try { + assertUsableApiKey('sk-\u{1F600}supersecret', 'llm-deepseek', 'DEEPSEEK_API_KEY') + expect.fail('an illegal key must throw') + } catch (error) { + expect((error as Error).message).not.toContain('supersecret') + } + }) +})