数据截至 (上游 commit d87b272aec54)
多协议模型层:一个接口接住 OpenAI / Anthropic / Gemini / Qwen
30 秒导读: 主循环(见 01 主循环)从头到尾只跟一个叫
ContentGenerator的接口打交道,它长得像 Google Gemini SDK。真正连 OpenAI、Anthropic、DashScope 还是自家 Qwen OAuth,由一个枚举值决定;各家协议的差异、私有字段和坏行为,全部被关在这一层里消化掉。
1. 这一层是什么(零基础也能懂)
一句话定义: 模型层是一个协议适配层——把「给模型发一段对话、拿回一段回复」这件事,抽象成一个固定接口,然后为每种模型 API 各写一个实现。
它解决什么问题。 你写了一个 coding agent,主循环大概是「组装消息 → 调模型 → 解析工具调用 → 执行 → 再调模型」。这套循环本身跟用哪个模型无关。但如果主循环里直接写 openai.chat.completions.create(...),那想换成 Claude 就得把循环重写一遍。
为什么这件事比想象中难。 不是「多写几个 if」的问题。真实的 OpenAI 兼容生态里,同一个 /v1/chat/completions 端点,不同厂商的行为能差出这些花样:
| 现实中的坑 | 具体表现 |
|---|---|
| 工具参数是碎的 | 一个 JSON 参数被切成几十个 SSE 片段,中途还可能被截断 |
| 思维链没有统一字段 | 有的用 reasoning_content,有的用 reasoning,有的干脆把 <think> 标签混在正文里 |
| 内容格式挑食 | DeepSeek 不吃 content parts 数组,只吃纯字符串 |
| 错误伪装成正常响应 | 限流错误以 finish_reason: "error_finish" 的正常 SSE 块返回,HTTP 状态码是 200 |
| 流会「装死」 | 返回 200 之后不再发任何块,SDK 的 timeout 管不到 |
| 关思考的开关每家都不一样 | enable_thinking: false / thinking: {type:'disabled'} / reasoning.effort / extra_body.thinking.enabled |
给谁用。 两类人:想换供应商的终端用户(改一份配置就行),以及想接入新厂商的贡献者(大多数情况下只需加一个 preset,不用写代码)。
用起来什么样。 用户视角就是一份 settings 里的模型供应商配置——挑一个 provider、填一个环境变量名。以 OpenRouter 为例(packages/core/src/providers/presets/openrouter.ts:13,openRouterProvider):
// 示意,非源码:这是 preset 的形状,用户只挑 id + 填环境变量
{
id: 'openrouter',
protocol: AuthType.USE_OPENAI, // 走 OpenAI 兼容那条路
baseUrl: 'https://openrouter.ai/api/v1',
envKey: 'OPENROUTER_API_KEY', // 去这个环境变量里取 key
customHeaders: { 'HTTP-Referer': '...' } // 只有 header 差异 → 不用写 provider 类
}
一句话直觉。 把它当成电源转换头:主循环是只认一种插头的电器,转换头负责把各国插座(各家 API)转成那一种插头,并且顺手稳压(修流式坏数据)。
2. 顶层全景(它大概怎么转)
怎么读这张图: 从上往下是一次调用的下沉路径;到 createContentGenerator 处按 AuthType 分叉,四条支路殊途同归地实现同一个接口。
主循环 / GeminiChat(只认 Gemini 类型的请求与响应)
│
▼
┌─────────────────────────────────┐
│ ContentGenerator(唯一契约) │ contentGenerator.ts:37
└─────────────────────────────────┘
│ createContentGenerator 按 AuthType 分叉
┌──────────┬───────┴────────┬──────────────┐
▼ ▼ ▼ ▼
openai anthropic gemini/vertex-ai qwen-oauth
│ │ │ │
▼ ▼ ▼ ▼
① 兼容管线 ② 原生 SDK ③ 原生 SDK ④ ①+动态令牌
pipeline Anthropic GoogleGenAI QwenContentGenerator
│
└─→ provider/ 按厂商打补丁(dashscope / deepseek / minimax / …)
每个部件一句话职责:
| 部件 | 干什么 | 在哪个文件 |
|---|---|---|
ContentGenerator | 5 个方法的接口,上层唯一认识的东西 | packages/core/src/core/contentGenerator.ts:37 |
AuthType | 五值枚举,决定走哪条支路 | packages/core/src/core/contentGenerator.ts:55 |
ContentGeneratorConfig | 一次调用需要的全部配置(key/baseUrl/超时/采样/思考开关…) | packages/core/src/core/contentGenerator.ts:74 |
createContentGenerator | 工厂:校验配置 → 动态 import 对应实现 → 套上日志外壳 | packages/core/src/core/contentGenerator.ts:343 |
ContentGenerationPipeline | OpenAI 兼容路径的主管线:建请求、发请求、转响应、兜错 | packages/core/src/core/openaiContentGenerator/pipeline.ts:279 |
OpenAICompatibleProvider | 厂商补丁接口:改 header、改 client、改请求体 | packages/core/src/core/openaiContentGenerator/provider/types.ts:26 |
ModelRegistry / resolveModelConfig | 把「用户配的模型/供应商」解析成一份带来源标注的 config | packages/core/src/models/modelRegistry.ts:86、modelConfigResolver.ts:147 |
LoggingContentGenerator | 装饰器外壳:遥测、span、OpenAI 格式请求日志 | packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts:103 |
主线走一遍(不进代码):
- 用户选定 auth 方式,
Config.refreshAuth()触发一次配置解析(packages/core/src/config/config.ts:2627)。 resolveContentGeneratorConfigWithSources校验并定稿一份ContentGeneratorConfig(config.ts:2617)。createContentGenerator按authType造出具体实现,再包一层LoggingContentGenerator(config.ts:2627)。- 之后主循环每一轮只调
generateContentStream(request, promptId),其它的它一概不知道。
3. 契约本身:ContentGenerator 与 AuthType
这节讲什么: 整层的地基只有两个声明,很短,值得逐字看。
3.1 五个方法
接口定义在 packages/core/src/core/contentGenerator.ts:37(ContentGenerator):
generateContent(request, userPromptId): Promise<GenerateContentResponse>
generateContentStream(request, userPromptId): Promise<AsyncGenerator<...>>
countTokens(request): Promise<CountTokensResponse>
embedContent(request): Promise<EmbedContentResponse>
useSummarizedThinking(): boolean
三点值得注意:
- 参数与返回值都是
@google/genai的类型。 也就是说这套抽象没有自造中立协议,而是直接把 Gemini SDK 的数据结构选作「共同语言」。历史原因(qwen-code 从 gemini-cli fork 而来),但也是个务实选择:省掉一层翻译。 userPromptId是接口的一部分。 它一路带到 DashScope 的metadata.promptId(provider/dashscope.ts:250,buildMetadata),用于服务端会话追踪。useSummarizedThinking()是唯一一个「问能力」的方法。 只有 Gemini 返回true(geminiContentGenerator.ts:303),OpenAI 与 Anthropic 实现都返回false(openaiContentGenerator.ts:167、anthropicContentGenerator.ts:340)——因为只有 Gemini 返回的是摘要过的思维,其它家给的是原始思维流。
3.2 五个 AuthType
packages/core/src/core/contentGenerator.ts:55(AuthType):
| 枚举值 | 字面量 | 走哪条实现 |
|---|---|---|
USE_OPENAI | openai | createOpenAIContentGenerator |
QWEN_OAUTH | qwen-oauth | QwenContentGenerator(继承 OpenAI 实现) |
USE_GEMINI | gemini | createGeminiContentGenerator |
USE_VERTEX_AI | vertex-ai | 同上,靠 vertexai: true 区分 |
USE_ANTHROPIC | anthropic | createAnthropicContentGenerator |
分叉逻辑就在 createContentGenerator(contentGenerator.ts:343-421),四个分支都是动态 import()——所以启动时不会把三家 SDK 全加载进来。它还专门处理了一个真实场景:如果用户在后台被自动更新,动态 import 会抛 ERR_MODULE_NOT_FOUND,代码把它翻译成「请重启 Qwen Code」的人话(contentGenerator.ts:409-416,getModuleNotFoundError)。
最后一行是关键:不管走哪条分支,返回的都是被 LoggingContentGenerator 包过的对象(contentGenerator.ts:420)。
4. 配置从哪来:ContentGeneratorConfig 的三段解析
这节讲什么: 这个接口能「换模型不换循环」,一半功劳在配置层——把所有厂商差异表达成数据,而不是代码分支。
4.1 config 里有什么
ContentGeneratorConfig(contentGenerator.ts:74)是个大对象。按用途分成四组看更清楚:
| 组别 | 字段 | 说明 |
|---|---|---|
| 身份 | model apiKey apiKeyEnvKey baseUrl vertexai authType | 连谁、用什么凭据 |
| 传输 | timeout streamIdleTimeoutMs maxRetries retryErrorCodes proxy userAgent customHeaders | 网络行为 |
| 生成 | samplingParams reasoning contextWindowSize extra_body | 模型行为 |
| 兼容 | schemaCompliance enableCacheControl modalities splitToolMedia toolResultContentFormat | 专门用来绕各家的坑 |
其中四个字段在这一层的分量最重:
streamIdleTimeoutMs(contentGenerator.ts:88)——流式空转看门狗。 注释直说了为什么需要它:SDK 的 timeout 只覆盖「连接 + 首个响应」,一个返回了 200 然后再不发块的流是无界的。<= 0 表示关闭。
samplingParams(contentGenerator.ts:92)——逃生舱口。 它有一条 [key: string]: unknown 索引签名,意味着任何键都会原样上线。这样用户想给 GPT-5 系发 max_completion_tokens、给别家发 reasoning_effort,都不用等客户端发版。代价见 §5.1.3。
reasoning(contentGenerator.ts:104)——思考开关。 类型是 false | { effort?, budget_tokens? }。effort 的取值里有个 'max',源码注释写明这是 DeepSeek 独有的扩展,Anthropic 那边只接受 low/medium/high,所以 Anthropic 实现会把它降级成 'high'(详见 §5.6)。
schemaCompliance(contentGenerator.ts:122)——工具 schema 方言。 只有两个取值:'auto'(原样透传)和 'openapi_30'(把现代 JSON Schema 降级到 OpenAPI 3.0)。降级逻辑在 packages/core/src/utils/schemaConverter.ts:18(convertSchema),典型动作是把 type: ["string","null"] 改写成 type: "string", nullable: true(schemaConverter.ts:48-54)。工具定义本身怎么来的,见 03 工具层。
4.2 三段解析
配置不是一次成型的,而是三段接力:
用户输入的各种来源 解析器 产物
──────────────────────────────────────────────────────────────────────
--model / --openaiApiKey ┐
OPENAI_API_KEY 等环境变量 ├─→ ① resolveModelConfig ──→ 带来源标注的
settings.model.generationConfig │ (modelConfigResolver Partial<config>
modelProviders 里的 preset ┘ .ts:147)
│
▼
② resolveContentGeneratorConfigWithSources
(contentGenerator.ts:187)
补 authType/proxy + 校验必填
│
▼
③ createContentGenerator
(contentGenerator.ts:343)
→ 真正的 ContentGenerator 实例
① resolveModelConfig(models/modelConfigResolver.ts:147) 是唯一的取值入口。优先级写在文件头注释里:modelProvider > CLI 参数 > 环境变量 > settings > 默认值。每个字段解析完都往 sources 里记一条「这个值从哪来的」(如 sources['model'] = cliSource('--model')),这在排查「我明明配了为什么不生效」时很有用。
AUTH_ENV_MAPPINGS(models/constants.ts:66)就是那张环境变量对照表——openai → OPENAI_API_KEY / OPENAI_BASE_URL / OPENAI_MODEL|QWEN_MODEL,anthropic → ANTHROPIC_*,依此类推。加一家新协议时,这里是要改的第一处。
② resolveContentGeneratorConfigWithSources(contentGenerator.ts:187) 现在不做兜底取值了,只做两件事:补上 authType 和 proxy 这两个「计算得来」的字段,然后调 validateModelConfig 校验。函数注释明确说 env fallback 已经上移到了统一 resolver,避免重复。
③ 校验规则(validateModelConfig,contentGenerator.ts:251) 很短,只有三条,但每条都有来由:
| 规则 | 代码位置 | 为什么 |
|---|---|---|
qwen-oauth 直接放行 | contentGenerator.ts:258 | 它用动态令牌,构造时压根没有 key |
其它 authType 必须有 apiKey 和 model | contentGenerator.ts:263、:287 | 缺一个就抛带环境变量名提示的错 |
anthropic 必须显式给 baseUrl | contentGenerator.ts:297 | 迁移自旧代码的硬约束 |
createContentGeneratorConfig(contentGenerator.ts:313)只是 ② 的一个丢掉 sources 的薄封装。
4.3 子代理怎么复用这套解析
多智能体(见 06 多智能体)里,一个子代理可能要跑在跟主进程不同的供应商上。这条路径复用同一套配置:buildAgentContentGeneratorConfig(models/content-generator-config.ts:44)继承父进程的传输类设置,但跨供应商时会把生成类字段全部清空(content-generator-config.ts:64-69),依据的清单是 MODEL_GENERATION_CONFIG_FIELDS(models/constants.ts:21)。理由很直白:父进程的 samplingParams、extra_body 对另一家 API 往往是非法字段。
5. 核心机制
5.1 OpenAI 兼容管线:一次请求的六道工序
它要解决的小问题: 把 Gemini 形状的请求翻译成 OpenAI 形状,发出去,再把响应翻译回来——并且在这条链上留出足够多的钩子,让厂商补丁能插进来。
流程(ContentGenerationPipeline,pipeline.ts:279):
executeWithErrorHandling (pipeline.ts:722)
│
├─① createRequestContext ......... 每次请求 造一份"随行状态" :770
│ └─ 新建 StreamingToolCallParser(流式才建)
├─② buildRequest ................. 组装 OpenAI 请求体 :505
│ ├─ convertGeminiRequestToOpenAI(消息)
│ ├─ convertGeminiToolsToOpenAI(工具 + schemaCompliance)
│ ├─ provider.buildRequest(厂商补丁在这里插入)
│ └─ reasoning 关闭时的三种"关思考"写法
├─③ 抓包钩子 ..................... 日志看到的就是上线字节 :744
├─④ SDK 调用 ..................... chat.completions.create :217 / :256
├─⑤ 流式:看门狗 + 逐块转换 ....... processStreamWithLogging :309
└─⑥ 出错:EnhancedErrorHandler .... 统一兜底 :764
第③步的位置是承重的,源码注释专门标了:抓包必须在 buildRequest 之后、SDK 调用之前,否则日志里看到的就不是真正发出去的字节(pipeline.ts:836-840)。