Files
claude-code/docs/outline-output/design/07-provider-dispatch.md
2026-06-15 16:51:29 +08:00

249 lines
13 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.
# 第七章7-Provider 抽象层的单一调度点
> 一个函数的精确位置,决定了六个兼容层"结构性跳过" Prompt 缓存和 beta 功能——不需要任何一个 feature flag。
## 为什么有 7 个 Provider却只有一个调度点
打开 `src/services/api/claude.ts:1344`,你会看到一个由三个连续 `if` + `return` 组成的调度块:
```ts
// claude.ts:1344-1382
if (getAPIProvider() === 'openai') {
const { queryModelOpenAI } = await import('./openai/index.js')
yield* queryModelOpenAI(messagesForAPI, systemPrompt, tools, signal, options)
return
}
if (getAPIProvider() === 'gemini') {
const { queryModelGemini } = await import('./gemini/index.js')
yield* queryModelGemini(messagesForAPI, systemPrompt, filteredTools, signal, options, thinkingConfig)
return
}
if (getAPIProvider() === 'grok') {
const { queryModelGrok } = await import('./grok/index.js')
yield* queryModelGrok(messagesForAPI, systemPrompt, filteredTools, signal, options)
return
}
```
三个非 Anthropic Provider 在这个位置被截走,各自的路径 `yield*` 事件后直接 `return`。执行流不会继续往下走。
往下走的是什么Anthropic 特有的逻辑——`betas` 注入(`claude.ts:1486`)、`thinking` 配置、`prompt caching``claude.ts:1480`)、`buildSystemPromptBlocks`。这些逻辑从第 1384 行一直延伸到函数末尾。兼容层 Provider 因为在第 1344-1382 行就 `return` 了,所以**结构性跳过**了所有 Anthropic 特有的功能。
这就是整个多 API 兼容层最核心的设计决策:不是用 feature flag 去禁用缓存和 beta而是让调度点的位置天然形成一条分界线。分界线之前的代码是共享的消息归一化、工具过滤、媒体剔除分界线之后的代码是 Anthropic 独占的。
如果不这么做——如果缓存逻辑在调度点之前运行——你就需要给每个非 Anthropic Provider 加 `if (provider === 'anthropic')` 的条件包裹。代码会变成条件分支的嵌套地狱,每加一个 Provider 就多一层。
## 调度点之前:共享预处理做了什么
`claude.ts` 函数入口到第 1344 行之间,所有 Provider 共用同一条预处理管道。按顺序:
1. **消息归一化**`claude.ts:1290`)——`normalizeMessagesForAPI(messages, filteredTools)` 把内部消息格式转成 API 需要的格式
2. **工具配对修复**`claude.ts:1325`)——`ensureToolResultPairing` 修复远程会话恢复时 tool_use/tool_result 不匹配的问题
3. **Advisor 块剥离**`claude.ts:1328-1330`——API 没有 advisor beta 头时会拒绝 advisor 块
4. **媒体剔除**`claude.ts:1336`——API 拒绝超过 100 个媒体项的请求,静默丢弃最旧的
这四步对七个 Provider 一视同仁。在 Anthropic 原生路径中,这四步之后还会继续走 betas 注入、缓存标记、thinking 配置。但兼容层在第 1344 行就截断了。
## 调度点的不对称tools vs filteredTools
仔细看第 1344-1382 行的三个分支,你会发现一个刻意的不对称:
- **OpenAI 路径**接收 `tools`**全池**
- **Gemini 路径**接收 `filteredTools`**裁剪后**
- **Grok 路径**接收 `filteredTools`**裁剪后**
`tools``filteredTools` 的区别在于延迟工具的过滤。打开 `claude.ts:1182-1205`
```ts
// claude.ts:1183-1205
let filteredTools: Tools
if (useSearchExtraTools) {
// Never include deferred tools in the API tools array
filteredTools = tools.filter(tool => {
if (!deferredToolNames.has(tool.name)) return true
if (toolMatchesName(tool, SEARCH_EXTRA_TOOLS_TOOL_NAME)) return true
return false
})
} else {
filteredTools = tools.filter(
t => !toolMatchesName(t, SEARCH_EXTRA_TOOLS_TOOL_NAME),
)
}
```
`useSearchExtraTools` 开启时,`filteredTools` 会排除所有延迟工具(除了 `SearchExtraToolsTool` 自身)。这些工具的 schema 不发给 API只在模型通过 `SearchExtraTools` 发现后才通过 `ExecuteExtraTool` 动态加载。
那为什么 OpenAI 路径需要**全池**?注释在 `claude.ts:1346-1348` 解释了原因:
```ts
// OpenAI emulates Anthropic's dynamic tool loading client-side. It needs
// the full tool pool so SearchExtraToolsTool can search deferred MCP tools that
// were intentionally filtered out of the initial API tool list above.
```
OpenAI 适配器(`src/services/api/openai/index.ts:214`)收到 `tools` 后,在内部做了自己的过滤逻辑(`index.ts:253-263`)。它保留全池是为了让 `SearchExtraToolsTool` 的 prompt 里能看到所有可搜索的 MCP 工具。Gemini 和 Grok 的适配器不需要这个——它们直接用传入的 `filteredTools` 构建请求。
这个不对称恰恰是"调度点位置精确"论点的最强证据:如果调度点在更前面(消息归一化之前),`filteredTools` 还没计算出来三个路径都无法做延迟工具优化。如果调度点在更后面Anthropic 逻辑之后),兼容层就需要处理 beta/caching 的副作用。当前这个精确位置——归一化之后、Anthropic 逻辑之前——是唯一的甜蜜点。
## getAPIProvider():单一真相源
打开 `src/utils/model/providers.ts:15`
```ts
// providers.ts:15-32
export function getAPIProvider(
settings: Pick<SettingsJson, 'modelType'> = getInitialSettings(),
): APIProvider {
const modelType = settings.modelType
if (modelType === 'openai') return 'openai'
if (modelType === 'gemini') return 'gemini'
if (modelType === 'grok') return 'grok'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) return 'bedrock'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) return 'vertex'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)) return 'foundry'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI)) return 'openai'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI)) return 'gemini'
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_GROK)) return 'grok'
return 'firstParty'
}
```
这个函数有三层优先级:
1. **`modelType` 参数**——来自 `settings.json` 的持久化配置(`/provider` 命令写入)
2. **`CLAUDE_CODE_USE_*` 环境变量**——Bedrock / Vertex / Foundry 的云 Provider 检测
3. **兜底 `firstParty`**——Anthropic 直连 API
注意 `bedrock``vertex``foundry` 只通过环境变量检测。打开 `src/commands/provider.ts:127-161`,你会看到 `/provider` 命令对这两类 Provider 的处理不同:
```ts
// provider.ts:129-161
if (
arg === 'anthropic' ||
arg === 'openai' ||
arg === 'gemini' ||
arg === 'grok'
) {
// 清除所有云 provider 环境变量
delete process.env.CLAUDE_CODE_USE_BEDROCK
delete process.env.CLAUDE_CODE_USE_VERTEX
delete process.env.CLAUDE_CODE_USE_FOUNDRY
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
updateSettingsForSource('userSettings', { modelType: arg })
applyConfigEnvironmentVariables()
return { type: 'text', value: `API provider set to ${arg}.` }
} else {
// 云 Provider只设环境变量不碰 settings.json
delete process.env.CLAUDE_CODE_USE_OPENAI
delete process.env.OPENAI_API_KEY
delete process.env.OPENAI_BASE_URL
delete process.env.CLAUDE_CODE_USE_GEMINI
delete process.env.CLAUDE_CODE_USE_GROK
process.env[getEnvVarForProvider(arg)] = '1'
applyConfigEnvironmentVariables()
return { type: 'text', value: `API provider set to ${arg} (via environment variable).` }
}
```
`/provider openai``settings.json`(下次启动仍生效),`/provider bedrock` 只设环境变量进程退出即消失。这个区分是有道理的Bedrock/Vertex/Foundry 的认证依赖 AWS/GCP/Azure 的 credential chain不适合持久化到用户配置文件。
切换时还有一个重要的原子性设计:**先清除所有竞争 Provider 的标记,再设置目标 Provider**。`/provider unset``provider.ts:49-61`)更彻底——同时删除所有 `CLAUDE_CODE_USE_*` 环境变量并清除 `modelType`
如果不做这个"全部清除再设置"的原子操作,用户从 `openai` 切到 `gemini` 时,`CLAUDE_CODE_USE_OPENAI=1` 可能残留在环境中,导致 `getAPIProvider()``modelType` 检查之后命中环境变量层,返回错误的 Provider。
## "类型谎言"4 个 SDK 伪装成 Anthropic
打开 `src/services/api/client.ts:84``getAnthropicClient()` 函数返回类型声明为 `Promise<Anthropic>`。但在函数体内部Bedrock、Vertex、Foundry 三个分支返回的是完全不同的 SDK 实例,通过 `as unknown as Anthropic` 强转:
```ts
// client.ts:189 — Bedrock
return new BedrockClient(bedrockArgs) as unknown as Anthropic
// client.ts:219 — Foundry
return new AnthropicFoundry(foundryArgs) as unknown as Anthropic
// client.ts:297 — Vertex
return new AnthropicVertex(vertexArgs) as unknown as Anthropic
```
注释甚至承认了这个"谎言"
```ts
// client.ts:188
// we have always been lying about the return type - this doesn't support batching or models
```
`BedrockClient``AnthropicFoundry``AnthropicVertex` 各自有不同的构造参数、不同的认证方式、不同的 region 处理。但它们的 SDK 都实现了与 `Anthropic` 类似的 `messages.create()` 接口所以下游代码可以统一调用。这是一个鸭子类型duck typing的实用主义选择——不依赖 TypeScript 的类型系统来保证接口兼容,而是靠运行时的 API 契约。
反事实推演:如果为每种 SDK 定义独立的类型(`BedrockClient | AnthropicVertex | AnthropicFoundry | Anthropic`),下游 `claude.ts` 中每处调用都需要联合类型缩窄。代码量至少翻三倍,但安全性收益微乎其微——三个云 SDK 都是 Anthropic 官方发布的,接口一致性有保障。
## isFirstPartyAnthropicBaseUrl() 的 TODO 陷阱
回到 `providers.ts:43-59`
```ts
// providers.ts:43-59
export function isFirstPartyAnthropicBaseUrl(): boolean {
const baseUrl = process.env.ANTHROPIC_BASE_URL
// TODO: 这里会有问题, 只配置了 openai 协议的用户, 按理说会为 true 导致问题
if (!baseUrl) {
return true
}
try {
const host = new URL(baseUrl).host
const allowedHosts = ['api.anthropic.com']
if (process.env.USER_TYPE === 'ant') {
allowedHosts.push('api-staging.anthropic.com')
}
return allowedHosts.includes(host)
} catch {
return false
}
}
```
这个函数在多处被调用,用来判断"当前是否使用 Anthropic 官方 API"。问题在于:当用户只设了 `OPENAI_BASE_URL` 而没设 `ANTHROPIC_BASE_URL` 时,`baseUrl` 为空,函数返回 `true`。但如果 `getAPIProvider()` 返回的是 `openai`(因为 `modelType='openai'``CLAUDE_CODE_USE_OPENAI=1``isFirstPartyAnthropicBaseUrl()` 仍然说"是 firstParty"。
这个不一致可能导致 firstParty 专有的行为(比如 prompt caching 的启用逻辑)泄漏到 OpenAI 兼容路径。TODO 注释已经指出了这个坑,但至今未修复。
## Langfuse 追踪也依赖单一真相源
打开 `claude.ts:2997-2999`
```ts
// claude.ts:2997-2999
recordLLMObservation(options.langfuseTrace ?? null, {
model: resolvedModel,
provider: getAPIProvider(),
// ...
})
```
Langfuse 的 `recordLLMObservation` 直接调用 `getAPIProvider()` 获取 provider 字段。这意味着所有可观测性数据——token 消耗、延迟、模型使用——都绑定在同一个真相源上。如果有人绕过 `getAPIProvider()` 用其他方式判断当前 Provider比如直接读 `process.env.CLAUDE_CODE_USE_OPENAI`Langfuse 追踪就会出现不一致。
## 为什么 Bedrock / Vertex / Foundry 不在调度点
你可能注意到,`claude.ts:1344-1382` 的调度块只处理 `openai``gemini``grok` 三个 Provider。Bedrock、Vertex、Foundry 去哪了?
答案是:它们在 `getAnthropicClient()``client.ts:84`)层面就被替换了。`claude.ts` 调用 `getAnthropicClient()` 时,如果环境变量 `CLAUDE_CODE_USE_BEDROCK=1`,拿到的 `client` 实例已经是 `BedrockClient` 了——但它的类型被伪装成 `Anthropic`。后续的 `client.messages.create()` 调用走的是 Bedrock SDK 的实现。
这意味着 Bedrock/Vertex/Foundry **不走调度点的兼容路径**,而是走 Anthropic 原生路径的全部逻辑——包括 betas、thinking、prompt caching。它们能这么做是因为这三个 SDK 本来就是 Anthropic 官方发布的,接口与 `Anthropic` SDK 高度一致,不需要消息格式转换。
只有真正"非 Anthropic"的 ProviderOpenAI 协议、Gemini 原生 API、Grok/xAI才需要独立的流适配器和调度分支。
如果不这么区分Bedrock/Vertex/Foundry 也要经过 OpenAI 式的消息转换——但它们本来就能接受 Anthropic 原生格式,转换纯属浪费且引入额外的序列化/反序列化误差。
## 延伸阅读
- 想看流适配器如何把 OpenAI/Gemini/Grok 的流格式转成 Anthropic 的 `BetaRawMessageStreamEvent`,见 [第八章:流适配器](./08-stream-adapters.md)
- 想看 Usage 字段映射和模型映射的四级优先级链,见 [第九章Usage 字段映射与模型映射](./09-usage-model-mapping.md)
- 想看 Feature Flag 如何在构建期替换 `feature()` 调用,见 [第六章Feature Flag 系统的三个硬约束](./06-feature-flags.md)