Merge remote-tracking branch 'origin/master' into worktree/default-model-persistence

Carries two edits beyond conflict resolution, both forced by what master
brought in:

- `CustomProviderCard`: master added front-end key validation and a
  component-level `keyValue` (already trimmed) while still writing
  `apiKeyEnv` unconditionally. Kept this branch's blank-key rule and its
  committed-profile retry gate, and adopted master's single `keyValue` so
  the component has one spelling of the key rather than two.
- `docs/user/guide/providers`: master merged #1810, whose default-model
  section still taught overriding the `api-gateway` row in
  `$DSH_HOME/config.yaml` — the behavior this branch replaced. Rewritten
  for the settings section the picker now writes, plus the review fix from
  #1810 replacing the colloquial 挂着 in the opener.
This commit is contained in:
Yichen Jiang
2026-08-07 15:44:17 +08:00
91 changed files with 1940 additions and 117 deletions

View File

@@ -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/llm/llm/README.md
README.md: 47fb73cd710a269bc0ac768ef475b56c99b98929
README.zh.md: b46ecf8ad1904630094b2c2b22ea421a406f4d79
README.md: d15b2c996d6d47371a3d6c5542eb5c253029dbae
README.zh.md: d965f15298f09ff9c2a953a69346c4e83136934b

View File

@@ -63,6 +63,10 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
Every product adapter sends application identity on provider HTTP requests. `attributionHeaders(identity?)` builds the standard `User-Agent`, defaulting to public `APP_IDENTITY`; white-label deployments may replace but not suppress it. Adapters verify the wire header directly or through their library hook. See [the attribution Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
### API key validation (`api-key.ts`)
Every adapter that puts a credential in an HTTP header judges it the same way before use. `normalizeApiKey(raw)` trims surrounding whitespace, then accepts any non-empty printable-ASCII value (`/^[\x21-\x7E]+$/`, space excluded) or reports why not as an `ApiKeyRejection` (`'empty'` | `'illegalCharacters'`), both carried in the `ApiKeyCheck` result. Absence is never judged: a caller decides whether a value was supplied before asking, since a profile naming no credential authenticates through the provider's own ambient discovery or OAuth.
### Classes
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
@@ -73,6 +77,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
- `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits.
- `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default.
- `INVALID_CREDENTIAL_CODE` — the provider-neutral code for a credential that was supplied but cannot be used: malformed rather than absent, so the fix is to correct the stored value rather than supply one — the distinction from `MISSING_CREDENTIAL`. Deliberately excluded from the default retryable set, since a malformed credential fails identically on every attempt. `assertUsableApiKey(raw, pkg, ref)` throws `LlmError` with this code, the one shared diagnosis every adapter uses for an unusable stored credential.
### Real adapters

View File

@@ -63,6 +63,10 @@
每个产品适配器都会在提供方 HTTP 请求上发送应用身份。`attributionHeaders(identity?)` 构建标准 `User-Agent`,默认为公开 `APP_IDENTITY`;白标部署可以替换它,但不能抑制它。适配器会直接验证 wire 标头,或通过自身库 hook 验证。详见 [归因 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md)。
### API 密钥校验(`api-key.ts`)
每个要把凭据放进 HTTP 标头的适配器,使用前都以同一套规则校验它。`normalizeApiKey(raw)` 先去除首尾空白,再接受任意非空的可打印 ASCII 值(`/^[\x21-\x7E]+$/`,不含空格),否则以 `ApiKeyRejection`(`'empty'` | `'illegalCharacters'`)说明拒绝原因,二者一并包含在 `ApiKeyCheck` 结果中。缺失从不参与校验:调用方会在询问之前自行判断是否提供了值——未点名凭据的 profile 会转由提供方自身的环境发现或 OAuth 完成认证。
### 类
- `LlmAdapter`:提供方适配器的抽象基类。唯一必需方法是 `stream()`。
@@ -73,6 +77,7 @@
- `CONTEXT_WINDOW_EXCEEDED_CODE`:当请求超过模型上下文窗口时,无论通过 HTTP 异常抛出还是带内 finish 交付,两个 DeepSeek 适配器都使用的提供方无关 code。`isContextWindowExceededError(detail)` 是它们针对 OpenAI 兼容提供方详细信息的共享保守分类器。
- `QUOTA_EXCEEDED_CODE`:帐户配额、余额、点数、预算或用量限制耗尽时使用的非短暂提供方无关 code。`isQuotaExceededError(detail)` 使这些失败与请求速率限制保持区分。
- `EMPTY_RESPONSE_CODE`:两个适配器都使用的提供方无关 code,用于表示退化的提供方生成结果:一个未携带任何内容块的终止 `stop`。它会被分类为错误 finish(而非成功空消息),因为尝试未产生持久内容;`dsh-llm-retry` 默认重试它。
- `INVALID_CREDENTIAL_CODE`:已提供但无法使用的凭据所用的提供方无关 code——格式错误而非缺失,修复方式是改正已存储的值而非补供一个,这正是它与 `MISSING_CREDENTIAL` 的区别。它被刻意排除在默认可重试集合之外:格式错误的凭据每次尝试都会以同样方式失败。`assertUsableApiKey(raw, pkg, ref)` 会以该 code 抛出 `LlmError`,是每个适配器判定已存储凭据不可用时共用的诊断。
### 真实适配器

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,41 @@ 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
// The Models page is named as the writer it usually is, not as the only one:
// the same value can arrive from a hand-edited .env or a shell export in a
// composition that mounts no credentials seam at all, where directing the
// user to a page that deployment does not serve would be a dead end.
throw new LlmError(
checked.reason === 'empty'
? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key`
+ ' (the web Models page writes it) or export it in the launching environment'
: `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry;`
+ ` set ${ref} to the raw key alone (the web Models page writes it)`,
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 resolved from 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')
}
})
})