feat(llm): define the legal API key shape in the seam

This commit is contained in:
Yichen Jiang
2026-08-06 20:43:50 +08:00
parent 9ff7eb84f0
commit 5b842895a8
4 changed files with 153 additions and 1 deletions

View File

@@ -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 }
}

View File

@@ -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_-]`

View File

@@ -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. */

View File

@@ -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')
}
})
})