Files
claude-code/docs/outline-output/design/09-usage-mapping.md
2026-06-15 16:51:29 +08:00

339 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 第九章Usage 字段映射与模型映射的优先级链
> 四级优先级链、ANSI 清理、模块级缓存陷阱——兼容层里那些不能省的"丑"代码
## 模型映射不是查表那么简单
三个兼容层OpenAI、Gemini、Grok各自有一个 `resolve<Model>Model` 函数,都遵循同一套四级优先级链。但"遵循"的方式有微妙分歧,正是这些分歧暴露了每个 Provider 的历史包袱和设计权衡。
打开 `packages/@ant/model-provider/src/providers/openai/modelMapping.ts:36`,你会看到 `resolveOpenAIModel` 的完整实现:
```ts
export function resolveOpenAIModel(anthropicModel: string): string {
if (process.env.OPENAI_MODEL) {
return process.env.OPENAI_MODEL
}
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
const family = getModelFamily(cleanModel)
if (family) {
const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL`
const openaiOverride = process.env[openaiEnvVar]
if (openaiOverride) return openaiOverride
const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
const anthropicOverride = process.env[anthropicEnvVar]
if (anthropicOverride) return anthropicOverride
}
return DEFAULT_MODEL_MAP[cleanModel] ?? cleanModel
}
```
优先级链:`OPENAI_MODEL` > `OPENAI_DEFAULT_{FAMILY}_MODEL` > `ANTHROPIC_DEFAULT_{FAMILY}_MODEL` > `DEFAULT_MODEL_MAP[cleanModel]` > `cleanModel`passthrough
注意第五级当查表也找不到时OpenAI 选择把模型名原样传过去。这是一个隐式契约——Ollama、vLLM 等本地端点会收到 `claude-sonnet-4-20250514` 这样的 Anthropic 模型名,它们当然不认识,但也不会崩溃(大不了返回 404。这个 passthrough 是有意为之,让用户不需要为每个自定义端点手动配置映射。
### 正则推断模型家族
三个 Provider 共用同一个 `getModelFamily` 函数,逻辑完全一样:
```ts
function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null {
if (/haiku/i.test(model)) return 'haiku'
if (/opus/i.test(model)) return 'opus'
if (/sonnet/i.test(model)) return 'sonnet'
return null
}
```
用正则从模型名字符串推断家族,而不是查表。为什么?因为模型名不是静态的——`claude-sonnet-4-20250514``claude-sonnet-4-6``claude-3-5-sonnet-20241022` 全是不同的 key但都是 sonnet。如果用精确匹配每次新增模型版本都要更新三个映射表。正则 `/haiku|sonnet|opus/i` 是一个把"Anthropic 模型名中嵌入家族信息"这个约定利用到极致的 hack。
如果不这么做,每当 Anthropic 发布新模型opUs 4.7、sonnet 5...),三个 Provider 的映射表都要同步更新。反编译重建过程中这种多处同步是最容易遗漏的地方——一个表更新了、另一个忘了,就会导致某个 Provider 下 opus 请求被错误地映射成默认模型。
注意检查顺序haiku 先于 opus、opus 先于 sonnet。如果顺序反过来一个包含 `opus` 的模型名会被错误地先匹配到 `sonnet`。但等等——为什么 `opus` 会被匹配到 `sonnet`?因为 `sonnet` 不包含 `opus` 子串。这个顺序实际上目前不会造成误匹配,但如果未来有一个叫 `super-sonnet-opus` 的模型呢?正则 `test()` 是子串匹配,不是词匹配——这个陷阱目前 dormant但很脆弱。
## Gemini唯一会硬抛异常的映射
打开 `packages/@ant/model-provider/src/providers/gemini/modelMapping.ts:8`,对比 OpenAI 的同一个函数:
```ts
export function resolveGeminiModel(anthropicModel: string): string {
if (process.env.GEMINI_MODEL) {
return process.env.GEMINI_MODEL
}
const cleanModel = anthropicModel.replace(/\[1m\]$/i, '')
const family = getModelFamily(cleanModel)
if (!family) {
return cleanModel
}
const geminiEnvVar = `GEMINI_DEFAULT_${family.toUpperCase()}_MODEL`
const geminiModel = process.env[geminiEnvVar]
if (geminiModel) {
return geminiModel
}
const sharedEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL`
const resolvedModel = process.env[sharedEnvVar]
if (resolvedModel) {
return resolvedModel
}
throw new Error(
`Gemini provider requires GEMINI_MODEL or ${geminiEnvVar} (or ${sharedEnvVar} for backward compatibility) to be configured.`,
)
}
```
关键差异Gemini 在四级优先级全部 miss 时**直接 throw Error**。OpenAI passthrough、Grok 也有 `DEFAULT_FAMILY_MAP` 兜底,只有 Gemini 拒绝猜测。
为什么?因为 Gemini 的模型命名空间和 Anthropic 完全不同——把 `claude-sonnet-4-20250514` 传给 Gemini API 会得到一个明确的 400 错误,而不是"用默认模型"的 graceful degradation。Gemini 团队选择 fail-fast与其让用户困惑于一个他们没配置过的模型在 Gemini 上跑出不可预期的结果,不如直接报错,强制用户配置映射。
反事实推演:如果 Gemini 也做 passthrough用户配好了 `CLAUDE_CODE_USE_GEMINI=1``GEMINI_API_KEY`,但忘了配 `GEMINI_MODEL`,请求会发送到 Google 的 API endpointAPI 返回 400 或 404错误信息可能被 OpenAI stream adapter 捕获并包装成一个令人困惑的 "API Error: model not found"。用户会以为 Gemini 不可用,而不是"我忘了配模型映射"。显式 throw 给出了精确的错误信息,直接指向解决方案。
## Grok唯一支持用户自定义 JSON 映射的 Provider
打开 `packages/@ant/model-provider/src/providers/grok/modelMapping.ts:34`,你会看到一个其他 Provider 都没有的特性:
```ts
function getUserModelMap(): Record<string, string> | null {
const raw = process.env.GROK_MODEL_MAP
if (!raw) return null
try {
const parsed = JSON.parse(raw)
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, string>
}
} catch {
// ignore invalid JSON
}
return null
}
```
通过 `GROK_MODEL_MAP` 环境变量,用户可以传入一个完整的 JSON 对象来自定义映射。在 `resolveGrokModel` 中,这个用户映射被插在 `GROK_MODEL` 全局覆盖和 `GROK_DEFAULT_{FAMILY}_MODEL` 家族覆盖之间(`modelMapping.ts:59`),形成了一个五级优先级链:
`GROK_MODEL` > `GROK_MODEL_MAP[family]` > `GROK_DEFAULT_{FAMILY}_MODEL` > `ANTHROPIC_DEFAULT_{FAMILY}_MODEL` > `DEFAULT_MODEL_MAP` > `DEFAULT_FAMILY_MAP` > `cleanModel`
为什么只有 Grok 有这个xAI 的 Grok 模型更新频繁(`grok-3-mini-fast` -> `grok-4.20-reasoning`),且用户经常在多个 Grok 模型之间切换做 A/B 测试。一个 JSON 环境变量比四个 `GROK_DEFAULT_*_MODEL` 变量更灵活。这是一个"用户需求驱动"的 API 设计,而不是架构统一性的产物。
注意 `catch` 分支的静默忽略——如果 `GROK_MODEL_MAP` 不是合法 JSON函数返回 `null`,相当于用户没有配置映射。没有 warning、没有 log。这种静默失败在 CLI 工具中很常见:在非交互模式下向 stderr 输出 warning 可能会干扰下游脚本。
## 防御性清理ANSI 加粗后缀
三个 `resolve` 函数开头都有同一行:
```ts
const cleanModel = anthropicModel.replace(/\[1m\]$/, '')
```
这剥离了模型名末尾的 ANSI 终端加粗转义序列 `\x1b[1m`。为什么会有人在模型名里嵌入 ANSI 代码?
答案在 REPL 屏幕的显示逻辑里——某些 UI 组件会把模型名渲染成粗体用于高亮显示,但如果后续代码不小心把显示值当成了数据值传进了 API 调用链,模型名就会带上 `\x1b[1m` 后缀。这行 `replace` 是一个防御性修复:它假设 bug 在上游显示逻辑在下游API 调用)拦截。
OpenAI 的版本用的是不带 `i` flag 的 `/\[1m\]$/`,而 Gemini 用了 `/\[1m\]$/i`。大小写不敏感 vs 敏感的差异,说明这个清理逻辑是在不同时间由不同人添加的,没有统一。这正是反编译重建项目的典型特征——同一个 bug 被修了两次,修法不完全一致。
## 模块级 Client 缓存:改 API key 必须重启
打开 `src/services/api/openai/client.ts:15`,你会看到:
```ts
let cachedClient: OpenAI | null = null
export function getOpenAIClient(options?: {
maxRetries?: number
fetchOverride?: typeof fetch
source?: string
}): OpenAI {
if (cachedClient) return cachedClient
// ... 创建 client ...
if (!options?.fetchOverride) {
cachedClient = client
}
return client
}
```
模块级变量 `cachedClient` 在第一次调用后持住,后续调用直接返回。问题在于:`apiKey``baseURL` 在构造时就固化在 OpenAI 实例内部了。如果用户在会话中途修改了 `OPENAI_API_KEY` 环境变量,`getOpenAIClient()` 仍然返回旧 client。
对比 `src/services/api/client.ts:84``getAnthropicClient`——它**每次调用都创建新实例**,不缓存。因为 Anthropic client 的构造逻辑较重OAuth token 刷新、AWS credential 获取但每次调用都重新读取环境变量。两种设计的根本差异是OpenAI/Grok 用缓存换取快速启动Anthropic 用无缓存换取配置热更新。
Grok 的 `src/services/api/grok/client.ts:13` 是同样的模式——`let cachedClient: OpenAI | null = null`,同样有 `clearGrokClientCache()` 导出。
这就是为什么大纲里有一条特别提示:会话中改 API key 必须调用 `clearOpenAIClientCache()` 或重启。`/login` 命令在写入新凭证后,内部确实调用了 `clearOpenAIClientCache()``clearGrokClientCache()`,但如果用户直接 `export OPENAI_API_KEY=xxx` 而不走 `/login`,缓存就不会被清除。
如果不做模块级缓存,每次 API 调用都要 `new OpenAI(...)` 重新建立 HTTP 连接池,对于流式响应(每个 turn 可能持续数十秒),连接复用的收益是真实的。但缓存带来的配置不可变副作用,是这种设计必须付出的代价。
## Usage 字段映射:镜像设计打破"下游零分支"叙事
第八章讲流适配器时强调了一个叙事:下游代码不知道上游是什么 Provider`contentBlocks` 累加器完全零分支。但在 Usage 字段映射上,这个叙事有一个刻意设计的例外。
打开 `src/services/api/openai/openaiShared.ts:18`,你会看到 `updateOpenAIUsage`
```ts
export function updateOpenAIUsage(
current: {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
},
delta: {
input_tokens?: number
output_tokens?: number
cache_creation_input_tokens?: number
cache_read_input_tokens?: number
},
): typeof current {
return {
input_tokens: delta.input_tokens ?? current.input_tokens,
output_tokens: delta.output_tokens ?? current.output_tokens,
cache_creation_input_tokens:
delta.cache_creation_input_tokens !== undefined &&
delta.cache_creation_input_tokens > 0
? delta.cache_creation_input_tokens
: current.cache_creation_input_tokens,
cache_read_input_tokens:
delta.cache_read_input_tokens !== undefined &&
delta.cache_read_input_tokens > 0
? delta.cache_read_input_tokens
: current.cache_read_input_tokens,
}
}
```
再看 `src/services/api/claude.ts:3084``updateUsage`Anthropic 原生路径):
```ts
export function updateUsage(
usage: Readonly<NonNullableUsage>,
partUsage: BetaMessageDeltaUsage | undefined,
): NonNullableUsage {
if (!partUsage) {
return { ...usage }
}
return {
input_tokens:
partUsage.input_tokens !== null && partUsage.input_tokens > 0
? partUsage.input_tokens
: usage.input_tokens,
cache_creation_input_tokens:
partUsage.cache_creation_input_tokens !== null &&
partUsage.cache_creation_input_tokens > 0
? partUsage.cache_creation_input_tokens
: usage.cache_creation_input_tokens,
cache_read_input_tokens:
partUsage.cache_read_input_tokens !== null &&
partUsage.cache_read_input_tokens > 0
? partUsage.cache_read_input_tokens
: usage.cache_read_input_tokens,
output_tokens: partUsage.output_tokens ?? usage.output_tokens,
// ... 更多字段
}
}
```
两者是**镜像函数**——`openaiShared.ts` 的注释直接说 "Mirrors updateUsage() in claude.ts"。但为什么要维护两份几乎相同的函数,而不是抽一个共享的 `mergeUsage`
答案是语义差异。Anthropic 的 streaming API 返回**累积值**`message_start` 里 input_tokens 是总量,后续 `message_delta` 里的 input_tokens 永远是 0 或同一个值)。而 OpenAI 兼容层的流适配器把 Chat Completions 的 delta usage 转换成 Anthropic 格式时,某些事件可能携带显式的 0 值。`openaiShared.ts:35``> 0` guard 确保增量 0 不会覆盖掉之前累积的真实值。
`claude.ts:3079` 的注释精确解释了这个设计动机:
> Input-related tokens (input_tokens, cache_creation_input_tokens, cache_read_input_tokens) are typically set in message_start and remain constant. message_delta events may send explicit 0 values for these fields, which should not overwrite the values from message_start.
这是"下游零分支"叙事里唯一需要针对性修补的点。`contentBlocks` 累加器不需要区分 Provider但 Usage 累加必须区分——因为 Anthropic 的 `message_delta` 携带 0 值是正常行为OpenAI 适配器如果也发 0 值,必须被正确处理。
如果把这个 `> 0` guard 去掉,一次 OpenAI 请求中如果 `message_delta` 携带了 `cache_creation_input_tokens: 0`,累积的缓存 token 计数就会被静默清零。用户会看到 `/cost` 报告的缓存命中数突然从数百 tokens 跳到 0但 API 实际上已经命中了缓存。这种"数字撒谎"比报错更危险,因为用户不会主动排查一个看起来正常但偏低的数字。
### cache 字段保留策略的深层原因
`cache_creation_input_tokens``cache_read_input_tokens` 是 Anthropic 的 prompt caching 特有字段。OpenAI 和 Grok 根本没有这个概念。那为什么 OpenAI 兼容层的 usage 对象里还要有这两个字段?
`src/services/api/openai/index.ts:129`Grok 路径的 usage 初始化就包含了这两个字段:
```ts
let usage: {
input_tokens: number
output_tokens: number
cache_creation_input_tokens: number
cache_read_input_tokens: number
} = {
input_tokens: 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}
```
因为这两个字段会在 Langfuse 追踪、`/cost` 计算、token 统计等下游消费者中被引用。如果 OpenAI 路径的 usage 对象缺少这两个字段,下游代码要么需要 Provider 分支,要么在访问时 undefined。让所有 Provider 的 usage 结构保持一致,下游才能继续"零分支"。
这也是为什么 `openaiShared.ts` 要用 `> 0` guard 而不是简单的 `?? current``??` 只检查 `null``undefined`,不检查 `0`。当 OpenAI 适配器发出 `cache_creation_input_tokens: 0` 时,`??` 会用 0 覆盖累积值,`> 0` guard 则会保留累积值。这个细微的语义差异就是整个 Usage 镜像设计存在的理由。
## BedrockClient针对 SDK 漏洞的运行时补丁
打开 `src/services/api/bedrockClient.ts:29`,你会看到一个极短的类:
```ts
export class BedrockClient extends AnthropicBedrock {
async buildRequest(options: BuildRequestArg): Promise<BuildRequestRet> {
const req = await super.buildRequest(options)
const inner = (
req as unknown as { req?: { body?: unknown; headers?: unknown } }
)?.req
if (!inner || typeof inner.body !== 'string' || inner.body.length === 0) {
return req
}
let parsed: Record<string, unknown>
try {
parsed = JSON.parse(inner.body) as Record<string, unknown>
} catch {
return req
}
if (!('anthropic_beta' in parsed)) {
return req
}
delete parsed.anthropic_beta
const cleanedBody = JSON.stringify(parsed)
inner.body = cleanedBody
const byteLen = String(new TextEncoder().encode(cleanedBody).length)
const h = inner.headers
if (typeof Headers !== 'undefined' && h instanceof Headers) {
if (h.has('content-length')) h.set('content-length', byteLen)
} else if (h && typeof h === 'object') {
const asDict = h as Record<string, string>
if ('content-length' in asDict) asDict['content-length'] = byteLen
}
return req
}
}
```
这个类做了一件事:`super.buildRequest()` 构建完请求后,检查 body JSON 里是否包含 `anthropic_beta` 字段,如果有就删掉,然后更新 `content-length` header。
注释里说得很清楚(`bedrockClient.ts:4`):这是 `@anthropic-ai/bedrock-sdk` 版本 0.26.4 到 0.28.1 的一个 bug——SDK 把 `anthropic-beta` HTTP header 的值复制到了请求 body 里的 `anthropic_beta` 字段。Bedrock 的 Opus 4.7 端点会拒绝任何 body 里包含 `anthropic_beta` 的请求,返回 400 "invalid beta flag"。
为什么不在 SDK 修复后直接删除这个类?因为 `bedrockClient.ts:22` 的注释留了一条明确的退出路径:
> When upstream ships a fix, verify the probe in scripts/probe-bedrock-beta-fix.ts shows "bug reproduced: false", then delete this class.
这个 probe 脚本(`scripts/probe-bedrock-beta-fix.ts`)会动态 import `@anthropic-ai/bedrock-sdk`,调用 `buildRequest`,检查 body 里是否出现 `anthropic_beta`。当 SDK 修复了这个 bugprobe 报告 "bug reproduced: false",开发者就可以安全地删除 `BedrockClient`,让 `client.ts` 直接使用 `AnthropicBedrock`
注意 `as unknown as` 的双重断言链(`bedrockClient.ts:33``req as unknown as { req?: { body?: unknown; headers?: unknown } }`。这是反编译产物的典型痕迹——原始类型信息在反编译过程中丢失了,开发者只能通过运行时观察推断内部结构。`req.req.body` 这种嵌套是 Bedrock SDK 的内部实现细节,不在公共类型里。
如果不做这个补丁,所有使用 Bedrock + Opus 4.7 的用户都会在每个请求上收到 400 错误。这不是"优雅降级",是"完全不可用"。
## 延伸阅读
- 想看调度点如何把三个 Provider 路径统一接入,见 [第七章7-Provider 抽象层的单一调度点](./07-provider-dispatch.md)
- 想看流适配器如何把 OpenAI/Grok 响应翻译成 Anthropic 格式,见 [第八章:流适配器](./08-stream-adapters.md)
- 想看 `getAPIProvider()` 的优先级判定逻辑,见 [第七章7-Provider 抽象层的单一调度点](./07-provider-dispatch.md) 中"Provider 路由优先级链"一节