13 KiB
第七章:7-Provider 抽象层的单一调度点
一个函数的精确位置,决定了六个兼容层"结构性跳过" Prompt 缓存和 beta 功能——不需要任何一个 feature flag。
为什么有 7 个 Provider,却只有一个调度点
打开 src/services/api/claude.ts:1344,你会看到一个由三个连续 if + return 组成的调度块:
// 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 共用同一条预处理管道。按顺序:
- 消息归一化(
claude.ts:1290)——normalizeMessagesForAPI(messages, filteredTools)把内部消息格式转成 API 需要的格式 - 工具配对修复(
claude.ts:1325)——ensureToolResultPairing修复远程会话恢复时 tool_use/tool_result 不匹配的问题 - Advisor 块剥离(
claude.ts:1328-1330)——API 没有 advisor beta 头时会拒绝 advisor 块 - 媒体剔除(
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:
// 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 解释了原因:
// 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:
// 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'
}
这个函数有三层优先级:
modelType参数——来自settings.json的持久化配置(/provider命令写入)CLAUDE_CODE_USE_*环境变量——Bedrock / Vertex / Foundry 的云 Provider 检测- 兜底
firstParty——Anthropic 直连 API
注意 bedrock、vertex、foundry 只通过环境变量检测。打开 src/commands/provider.ts:127-161,你会看到 /provider 命令对这两类 Provider 的处理不同:
// 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 强转:
// 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
注释甚至承认了这个"谎言":
// 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:
// 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:
// 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"的 Provider(OpenAI 协议、Gemini 原生 API、Grok/xAI)才需要独立的流适配器和调度分支。
如果不这么区分,Bedrock/Vertex/Foundry 也要经过 OpenAI 式的消息转换——但它们本来就能接受 Anthropic 原生格式,转换纯属浪费且引入额外的序列化/反序列化误差。
延伸阅读
- 想看流适配器如何把 OpenAI/Gemini/Grok 的流格式转成 Anthropic 的
BetaRawMessageStreamEvent,见 第八章:流适配器 - 想看 Usage 字段映射和模型映射的四级优先级链,见 第九章:Usage 字段映射与模型映射
- 想看 Feature Flag 如何在构建期替换
feature()调用,见 第六章:Feature Flag 系统的三个硬约束