格式转换层落在哪:三套协议适配器的位置与边界

2026-08-10

如果你把 CC Switch 的本地代理想象成「中间放了一个万能翻译器,进来什么协议出去什么协议」,那么翻开 src-tauri/src/proxy/ 之后第一件让人意外的事是:适配器只有三套实现。README 自称这个应用支持的工具有一长串(那是应用层面的支持口径,不等于「能被这个代理接管」),而 src-tauri/src/proxy/providers/mod.rs:256-264 里的 get_adapter 只分派出 Claude、Codex、Gemini 三种适配器,剩下的全是复用。至于代理侧到底覆盖几个应用,本文后面会给出三处可核实的口径,先按下不表。

第二件意外的事更关键:真正决定「这一条请求要不要做格式转换」的开关,不在适配器里,也不按工具划分。它是一个按供应商判定的布尔值,落在响应处理的入口上。

以下全部基于我们本地 clone 的 cc-switch 仓库快照 c39c903(提交日期 2026-08-10),仓库内版本号 3.19.2。我们只读源码与文档文本,没有安装也没有运行过这个桌面应用

先看体量:转换相关的代码占了这个模块的多大一块

我们采集时(2026-08-10,仓库快照 c39c903),src-tauri/src/proxy/find src-tauri/src/proxy -name '*.rs' | wc -l 数是 66 个 .rs 文件xargs wc -l 合计 61198 行。这 66 个文件里,顶层 31 个,providers/ 顶层就占了 28 个,另有 providers/models/ 3 个、usage/ 4 个。

也就是说,接近一半的文件挂在 providers/ 这个「协议适配」目录下。同一次采集里,单文件行数排前三的是 forwarder.rs 5023 行、providers/transform_codex_chat.rs 4558 行、handlers.rs 3348 行——三个里有一个就是转换文件。

这个体量本身说明了一件事:格式转换在这个仓库里不是一个薄薄的适配层,它是主体工作量之一。

三套适配器,八个 AppType

get_adapter 的分派关系是这样的(providers/mod.rs:256-264):

AppType实际拿到的适配器
ClaudeClaude 适配器
ClaudeDesktop复用 ClaudeAdapter
CodexCodex 适配器
GrokBuild复用 CodexAdapter
OpenCode复用 CodexAdapter
OpenClaw复用 CodexAdapter
Hermes复用 CodexAdapter
GeminiGemini 适配器

这张表的读法不是「这四个工具是一样的」,而是:在协议形态这一个维度上,它们被归到了同一套转换实现下。工具之间的其它差异(配置文件路径、会话 ID 怎么取、模型名怎么写)不由适配器负责,另有模块处理——比如 session.rs:5-11 就单独为 Claude、Codex、Grok Build 各写了一套 Session ID 提取优先级,Grok Build 取的是 x-grok-conv-id / x-grok-session-id,跟 Codex 的 header 完全不同。适配器复用,不等于这一路请求上下全都复用。

顺带一处可核实的口径差,正好落在「到底覆盖几个应用」上:types.rs:111-119ProxyTakeoverStatus 结构体有 6 个字段(claude / codex / gemini / grokbuild / opencode / openclaw),而数据库 proxy_config 表的 CHECK 约束只允许 'claude','codex','gemini','grokbuild' 四个值(src-tauri/src/database/schema.rs:126-127),用户手册 4.1 与 4.2 讲代理时只展开 Claude / Codex / Gemini 三个(docs/user-manual/zh/4-proxy/4.1-service.md:134-1514.2-routing.md:86)。三处数不一样,位置我们都标出来了,你可以自己去核。至于哪一处「才算数」,本文不做推断。

「格式转换」被切成了五段,适配器只管其中一段

真正让人容易误判的是:这件事不在一个地方发生。按请求生命周期上的五个位置来拆(这五段是逻辑分层,不严格等于代码的执行先后),一条请求身上被动过的「格式」至少经过五层,而只有第三层是适配器。

第一层是路由入口。 协议在 URL 上就已经分好了。我们采集时,server.rs 里用 grep -c "\.route(" 数是 25 条路由:Claude 走 POST /v1/messages/claude/v1/messagesserver.rs:297-298),Codex 侧的 OpenAI Chat Completions 挂了四条路径(server.rs:309-321),Responses API 又是四条加 Grok Build 独立的 /grokbuild/v1/responsesserver.rs:326-335)。Gemini 最特别,用 any(..)全部 HTTP 方法挂到 /v1beta/*path 等三条路径上,代码注释写明了原因:只挂 POST 会让 SDK 的 GET /models 在路由层 404,从而绕过统计、整流与故障转移(server.rs:357-366)。这一条值得单独记住——路由的形状是被下游 SDK 的实际行为倒逼出来的。

第二层是请求体预处理,发生在适配器之前。 model_mapper.rs:69-113 按 fable → haiku → opus → sonnet → default 的优先级做模型映射,fable 未单配时降级到 opus 档;model_mapper.rs:147-159 还负责把 [1M] 这个 Claude Code 的本地上下文能力标记在转发前剥掉。gemini_url.rs:6-20models/gemini-2.5-pro 这类 resource-name 形式归一,否则会拼出 /v1beta/models/models/... 被上游拒绝、把健康检查变成假阴性。发送前还有一道 prepare_upstream_request_body,等于 canonicalize_value(filter_private_params_with_whitelist(body, &[]))forwarder.rs:1581forwarder.rs:3485-3487),前者按 json_canonical.rs:1-21 排序对象键做稳定哈希,后者按 body_filter.rs:1-16 递归过滤下划线开头的私有参数,且明确让 JSON Schema 的 properties / patternProperties / definitions / $defs 下的名字免于此规则。

这些都是在改写请求的形状,但它们一个都不在适配器里

第三层才是适配器。 位置很具体:handlers.rs:232-258。转发成功拿到响应之后,代码取 get_adapter(&app_type) 得到适配器,再调 adapter.needs_transform(&ctx.provider);为真就走 handle_claude_transform,为假就走 process_response 的透传路径。

第四层是传输层的字节细节。 服务器不是标准 axum serve,而是手写 hyper HTTP/1.1 accept loop,开了 preserve_header_case(true),目的就是让转发到上游的 header 大小写与 CLI 直连时保持一致(server.rs:5-9server.rs:194-196)。

这里要单独说清一件事:请求侧那半段其实发生在整条链路的最前面——在 hyper 解析之前就先 stream.peek 最多 8192 字节原始 TCP 数据,把 header 的原始大小写抓进 OriginalHeaderCases 扩展(server.rs:159-175,解析实现在 hyper_client.rs:24-32),比上面说的第一层路由入口还早。之所以把它归在这一层,是因为它和响应侧的解压、分帧属于同一类关注点(字节而不是语义),并不是说它执行得晚。

响应侧才是真正排在后面的:因为禁用了 reqwest 的自动解压(为了透传 accept-encoding),content_encoding.rs:1-28 只能手工解压,支持 gzip / x-gzip / deflate / br / zstd / zst 并允许逗号堆叠编码。流式那一侧,sse.rs:7-32take_sse_block 同时支持 \r\n\r\n\n\n 两种分隔并取更靠前的那个,append_utf8_safe 专门处理跨 chunk 被切断的多字节 UTF-8。

第五层是用量解析。 usage/parser.rs:1-8 支持四类形态的流式与非流式两种解析。注意它是独立于适配器的另一套形态识别——转换归转换,统计归统计。

反直觉的那一处:开关按供应商,不按工具

回到 handlers.rs:232-258 这一行:adapter.needs_transform(&ctx.provider) 的入参是当前这条请求命中的 provider,不是 app_type。

这意味着:同一个工具、同一套适配器之下,换一家供应商,走的可能是完全不同的两条路——一条进 handle_claude_transform 做格式转换,另一条进 process_response 原样透传。所谓「CC Switch 帮我做协议转换」这句话,准确的说法是「它对某些供应商做转换,对另一些不做」,而这个判定发生在每一条请求上。

这一点和故障转移叠在一起会更明显。forwarder.rs 的重试循环里,整流重试标记(signature / budget / media)是每个 provider 独立持有的,注释说明是为了不让首家的标记短路后续 provider 的整流流程(forwarder.rs:417-421)。同一条请求在失败后转到第二家供应商,请求体的处理路径就得重新按新供应商的属性走一遍——包括 Bedrock 专属的优化器在 body 的 clone 上做、以免优化字段泄漏到 failover 之后的非 Bedrock provider(forwarder.rs:450-464)。

换句话说:转换与否是「请求 × 供应商」这个粒度上的属性,不是「工具」这个粒度上的属性。 你在排查「为什么同一个工具换一家上游行为就变了」时,这是第一个该看的地方。

边界:字段怎么映射,我们没读

必须把话说死:三个 transform 大文件的具体转换规则,我们没有读。

按我们采集时的行数,providers/transform_codex_chat.rs 4558 行、transform_codex_anthropic.rs 3020 行、transform_gemini.rs 2693 行——我们在 providers/ 的 28 个文件里只完整读了 adapter.rsmod.rsget_adapterstreaming_*.rs 与几个 OAuth 认证文件也没读。所以「Anthropic 的 content 数组是怎么翻成 OpenAI 的 messages 的」「tool_use 块怎么对应到 tool_calls」这类问题,本文只能告诉你去哪个文件找,给不出规则。同理,forwarder.rs 5023 行我们只读了约 500 行,handlers.rs 3348 行只读了函数清单和一个函数体,中间大段的 header 处理与 SSE 探测逻辑不在我们的核实范围内。

转换失败之后会发生什么倒是明确的:error_mapper.rs:7-32 的映射规则里,转换错误 → 422。这一行是本文唯一需要用到的一条——只留一条对照就够了:转发失败 → 502。两者摆在一起,正好把「转换环节出问题」和「网络环节出问题」在状态码上分开:你在 CLI 那头看到 422,该去翻的是 transform 那一路;看到 502,该去翻的是转发与上游连通。完整的错误码清单与错误到 HTTP 状态的整张映射表,我们另有一篇专门讲,这里不铺开。

你可以自己跑一遍的核查动作

不用装这个应用,clone 下来读文本就够:

# 1. 看适配器分派:三套实现覆盖八个 AppType
sed -n '250,270p' src-tauri/src/proxy/providers/mod.rs

# 2. 看转换开关那一行落在哪
sed -n '228,262p' src-tauri/src/proxy/handlers.rs

# 3. 数路由条目
grep -c "\.route(" src-tauri/src/proxy/server.rs

# 4. 看 providers/ 的体量分布
find src-tauri/src/proxy -name '*.rs' | wc -l
find src-tauri/src/proxy -name '*.rs' | xargs wc -l | sort -n | tail -5

以上为按仓库中的文件路径组合的核查命令,我们没有在本机执行过,以你本地 clone 的实际输出为准;Windows 侧建议在 Git Bash 或 WSL 里跑,PowerShell 下 grep / wc 需要自行替换成对应命令。

比对的两处是:providers/mod.rs 的分派表(八个 AppType 映射到三套实现)与 handlers.rsneeds_transform 入参(是 provider 不是 app_type)。这两处对上了,你就理解了这一层的形状。

文档侧还有一个入口值得一并翻:用户手册 docs/user-manual/zh/4-proxy/4.1-service.md(236 行)里有一张「API 格式转换表」,它是这件事在文档层的说法;同目录 4.2-routing.md(195 行)讲的是「请求转发五步」。要注意的是文档与代码在这一带存在可核实的差异——例如 4.1 的请求流程图把「记录请求日志/统计用量」画在「转发请求」之前(4.1-service.md:123-125),而代码里用量与日志是在拿到上游响应或流结束之后才写入(转发发生在 handlers.rs:200-258,落库由响应处理链路调用 usage/logger.rs)。差异陈述到此为止,我们不推断原因,也不拿它评价这个项目。

一句话收束

「协议适配器」这个词容易让人以为有一个集中的翻译中心。在这个仓库里它更像一条流水线上的一个工位:前面有路由分流、模型映射、URL 归一、私有参数过滤与键排序,后面有编码解压、SSE 分帧、UTF-8 续接与用量解析,适配器只在拿到上游响应之后被问一句「这家供应商要不要转」。

知道这一点的实际价值是:排查格式类问题时不要一头扎进 transform 文件。 先确认请求走的是哪条路由、模型名被映射成了什么、这家供应商的 needs_transform 是真是假,再决定要不要去读那四千多行。


本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册与发布说明、 src/config/ 的预设定义与 src-tauri/src/ 的后端源码整理,核对日 2026-08-10,对应仓库快照 c39c903。 本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用, 因此不涉及界面外观、操作手感与切换速度的任何描述。 文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。 该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。