本地代理模式做什么:一条请求经过的完整管线
「开代理模式」这四个字听起来像是把 CLI 的 base_url 指到本机某个端口,然后请求原样转出去。真读一遍 src-tauri/src/proxy/ 就会发现,这条路径上被动过手脚的地方比想象中多得多:请求体在发出去之前至少经过五道加工,响应回来还要手工解压再逐块计时,最后才落进数据库。
这篇不讲熔断阈值、不讲故障转移怎么挑下一家、不讲三个 thinking 整流器各修什么——那些各有专篇。这篇只干一件事:按代码顺序把一条请求走过的站点排出来,并把其中最反直觉的一站讲透。
以下全部基于我们本地 clone 的 cc-switch 仓库快照 c39c903(提交日期 2026-08-10),仓库内版本号 3.19.2。我们只读源码文本,没有安装也没有运行过这个桌面应用。
先给个体量参照:find src-tauri/src/proxy -name '*.rs' | wc -l 数出来是 66 个 .rs 文件,xargs wc -l 合计 61198 行,其中最大的三个是 forwarder.rs 5023 行、providers/transform_codex_chat.rs 4558 行、handlers.rs 3348 行。模块自己在 src-tauri/src/proxy/mod.rs:1-3 的注释里把定位写成「提供本地HTTP代理服务,支持多Provider故障转移和请求透传」。
第一站:TCP 层,那个反直觉的 peek
大多数人会预期这里是一句标准的 axum serve。它不是。src-tauri/src/proxy/server.rs 手写了一个 hyper HTTP/1.1 的 accept loop,并且在 server.rs:194-196 显式打开了 preserve_header_case(true)。
更反直觉的在前面一步。在把连接交给 hyper 解析之前,代码先对 socket 做了一次 stream.peek,最多读 8192 字节原始 TCP 数据,把 header 的原始大小写解析出来,塞进一个 OriginalHeaderCases 扩展里(server.rs:159-175,解析实现在 hyper_client.rs:24-32)。peek 只是「偷看」,不消费缓冲区,随后 hyper 仍然从头完整解析这条连接。
为什么要费这个劲?源码注释给的理由是:hyper 解析 header 名时会把它们小写化,而这个代理要让转发到上游的 header 大小写与 CLI 直连时保持一致(server.rs:5-9)。也就是说,它追求的不是「能转发」,而是「转发出去的东西在字节层面尽量像 CLI 自己发的」。这个取向会在后面反复出现。
顺带记一组边界值:请求体上限 DefaultBodyLimit::max(200 * 1024 * 1024),即 200 MiB(server.rs:368);上游响应体读取上限 MAX_RESPONSE_BODY_BYTES = 128 * 1024 * 1024(hyper_client.rs:77)。默认监听 127.0.0.1:15721,配置项还有 max_retries: 3、enable_logging: true,以及一个已标注「已废弃,保留兼容」的 request_timeout: 600(types.rs:42-56,废弃注释在 types.rs:12)。服务启动时会把实际端口写进全局 set_proxy_port,供系统代理检测认出自己的端口(server.rs:122-123、http_client.rs:22-32);停止服务时等待任务结束带 5 秒超时保护,超时返回 ProxyError::StopTimeout(server.rs:233-250)。
第二站:25 条路由,一个能力挂四个路径
grep -c "\.route(" src-tauri/src/proxy/server.rs 数出来是 25 条。把清单摊开会看到一个形状:同一个能力被挂在多个路径上,都指向同一个 handler。
以 OpenAI Chat Completions 那一组为例,server.rs:309-321 同时挂了 /chat/completions、/v1/chat/completions、/v1/v1/chat/completions、/codex/v1/chat/completions 四条。为什么会存在 /v1/v1/ 这种形状,仓库里我们没有读到任何说明,这里不做推断——只记录它确实存在于路由表里。Responses API 同样是四条(server.rs:326-329),Grok Build 另走独立 namespace /grokbuild/v1/responses(server.rs:330-335)。Claude 侧是 POST /v1/messages 与 POST /claude/v1/messages(server.rs:297-298),另有 Claude Desktop 的本地 gateway 两条(server.rs:300-307)。加上 GET /health、GET /status(server.rs:294-295)、两条 models 路由(server.rs:323-324)与 Responses Compact 的五条(server.rs:336-356)。
Gemini 那三条正好是个对照:/v1beta/*path、/gemini/v1beta/*path、/gemini/v1/*path 用的是 any(..),挂上了全部 HTTP 方法,而且这一处代码里写了理由——只挂 POST 的话,SDK 发的 GET /models 会在路由层直接 404,从而绕过统计、整流与故障转移(server.rs:357-366)。这是一条很典型的「路由粒度不够会静默丢功能」的记录。
第三站:handler 与转发前的五道加工
入口 handler 的顺序在 handlers.rs:169-210:读 body → serde_json 解析 → 构造 RequestContext → 取 endpoint → 判断 body 里的 stream 字段 → 调 forwarder.forward_with_retry(...)。注意流式与非流式在这里分岔,判据是请求体里的 stream 字段(handlers.rs:169-210)。
进入 forwarder 之后,每个 provider 尝试内部会对请求体做一串加工(forwarder.rs,按代码顺序):
- Bedrock 供应商且优化器开启时,先
thinking_optimizer::optimize再cache_injector::inject,并且在 body 的 clone 上做——注释说明这是为了避免优化字段泄漏到故障转移之后的非 Bedrock 供应商(forwarder.rs:450-464)。 - 模型映射
model_mapper::apply_model_mapping;ClaudeDesktop 走claude_desktop_config::map_proxy_request_model,未知 route 直接报错、不兜底(forwarder.rs:1159-1169)。 normalize_thinking_type,注释写明「请求前不做 thinking 主动改写(仅保留兼容入口)」(forwarder.rs:1171-1172)。- GrokBuild 走
apply_codex_upstream_model(forwarder.rs:1177-1179);Copilot 侧先做模型名归一化与在线解析,再strip_one_m_suffix_for_upstream_from_body(forwarder.rs:1181-1194)。 - 发送前最后一道
prepare_upstream_request_body,等于canonicalize_value(filter_private_params_with_whitelist(body, &[]))(forwarder.rs:1581、forwarder.rs:3485-3487)。
第 1 条那个 clone 是整段里最值得记的工程细节:加工是按供应商做的,而重试可能换供应商,所以加工不能就地改原 body。同一思路还体现在整流重试标记上——signature / budget / media 三个标记每个 provider 独立持有,注释说明是为了不让首家的标记短路后续 provider 的整流流程(forwarder.rs:417-421)。
第 5 条里的 filter_private_params_with_whitelist 来自 body_filter.rs:递归过滤下划线开头的私有参数,但 JSON Schema 的 properties/patternProperties/definitions/$defs 下的名字不按私有参数处理(body_filter.rs:1-16)——工具定义里合法的 _foo 字段不会被误伤。canonicalize_value 来自 json_canonical.rs:对象键排序后递归规范化,用于 cache 敏感请求体的稳定哈希(json_canonical.rs:1-21)。
模型映射的优先级也在这一层:fable → haiku → opus → sonnet → default,fable 未单配时降级到 opus 档(model_mapper.rs:69-113);[1M] 作为 Claude Code 的本地上下文能力标记,会在转发前被剥掉(model_mapper.rs:147-159)。
第四站:响应回程
拿到上游响应后,handlers.rs:232-258 先判断 adapter.needs_transform(provider):需要转换就走 handle_claude_transform,否则走 process_response 透传。也就是说,回程要不要转换是由 needs_transform 这一个判断决定的;协议适配器一共只有三种实现(providers/mod.rs:256-264),哪个应用复用哪个适配器、字段具体怎么映射,我们另有一篇专门讲,不在本文范围。
回程有两处细节和第一站是同一个取向。其一,content_encoding.rs 是手工解压:因为为了透传 accept-encoding 而禁用了 reqwest 的自动解压,只能自己处理 gzip / x-gzip / deflate / br / zstd / zst,还允许逗号堆叠编码;解压时把「输出超预算」与「数据损坏」区分成两类,前者调用方应回 502(content_encoding.rs:1-32)。其二,sse.rs 的 take_sse_block 同时支持 \r\n\r\n 与 \n\n 两种分隔符并取更靠前的那个,append_utf8_safe 专门处理跨 chunk 被切断的多字节 UTF-8(sse.rs:7-32)——中文流式输出被切在半个字上不至于乱码,靠的就是这一段。
流式转发的超时是两段式的:第一个 chunk 用 first_byte_timeout,之后改用 idle_timeout,两者填 0 则禁用;超时时 yield 一个「流式响应首字节/静默期超时」的 io error 并 break(response_processor.rs:700-737)。这两个值在数据库 proxy_config 表里按应用分别 seed,ProxyConfig 结构体注释给出的范围是:流式首字超时 1-120 秒(默认 60)、流式静默超时 60-600 秒(填 0 禁用)、非流式总超时 60-1200 秒(默认 600)(types.rs:19-27)。
第五站:落库,以及一处文档与代码的顺序差
请求最后落进 proxy_request_logs 表,主键 request_id(src-tauri/src/database/schema.rs:197-211)。字段里既有 token 四件套(input_tokens/output_tokens/cache_read_tokens/cache_creation_tokens),也有 latency_ms、first_token_ms、duration_ms、status_code、session_id、is_streaming 等,另建了 5 个索引(schema.rs:198-231)。成本计算用 rust_decimal::Decimal 高精度类型,注释写的理由是「避免浮点数精度问题」,价格按每百万 token 计(usage/calculator.rs:1-26)。去重靠 UsageSemantic 对一组字段做 SHA-256(usage/logger.rs:14-59)。
这里有一处可核实的差异:用户手册 docs/user-manual/zh/4-proxy/4.1-service.md:123-125 的流程图把「记录请求日志/统计用量」画在「转发请求」之前;而代码里转发在先(handlers.rs:200-258),用量与日志是在拿到上游响应或流结束之后才由响应处理链路写入。两处顺序不一致,以我们实读的仓库状态为准。至于哪一处才算数、为什么会这样,本文不做推断。
同一目录下的 4.1-service.md:36-40 还有一处口径差:基础配置表只列了三项(监听地址/端口/启用日志),而数据库 proxy_config 表里每个应用还有 max_retries、三个超时、五个熔断参数共 9 个可配置列(schema.rs:131-135),这些要翻到 4.3 才出现。说完就停。
你可以自己把这条管线核一遍
不需要装应用,按这个顺序读文件就行:
find src-tauri/src/proxy -name '*.rs' | wc -l
grep -c "\.route(" src-tauri/src/proxy/server.rs
grep -c "pub const" src-tauri/src/proxy/log_codes.rs
三条命令分别给出 66、25、27(最后一个是日志错误码数量,错误码表另有专篇)。然后按站点核:TCP 层看 server.rs:159-196,路由表看 server.rs:294-366,入口顺序看 handlers.rs:169-258,请求体加工看 forwarder.rs:1159-1194 与 forwarder.rs:1581,回程看 content_encoding.rs、sse.rs:7-32、response_processor.rs:700-737,落库看 database/schema.rs:197-231。哪一站你觉得本文说得不对,都能在这些行号上当场对质。
排查时也可以反过来用这条链路定位:回程失败时返回什么状态码,由 error_mapper.rs:7-32 统一映射。和本文这条管线直接相关的是两条——第四站那两个流式超时(首字节/静默期)映射到 504,转发失败映射到 502。所以看到 504 就该往第四站的两段式超时上查,看到 502 就该往转发本身查。完整的映射表与 27 个日志错误码我们另有一篇专门讲,这里不整表搬过来。
两条边界
第一,本文写到的所有阈值都是源码里的默认配置,不是运行结果的保证。 200 MiB、128 MiB、60 秒、600 秒这些值告诉你的是「代码写了什么」,不是「你会不会超时」。
第二,代理模式意味着本机跑着一个会接触你 API Key 的 HTTP 服务。 默认监听 127.0.0.1:15721,用户手册 4.1 专门讲了 127.0.0.1 与 0.0.0.0 的差别(4.1-service.md)。这属于本机敏感数据,怎么处置请结合自身环境判断,本文不给安全方案。
最后说清楚这篇没覆盖什么:providers/ 目录 28 个文件里我们只读了 adapter.rs 与 mod.rs 的 get_adapter,几个几千行的 transform 文件没读;forwarder.rs 5023 行只读了约 500 行,中间大段的 header 处理、OAuth 会话与 SSE 探测逻辑未核实;services/proxy.rs 7342 行完全没读。所以本文能给的是「管线有哪些站、每站在哪一行」,给不了「Anthropic 与 OpenAI 的字段具体怎么互转」。这条边界我们照实标出来。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册与发布说明、
src/config/ 的预设定义与 src-tauri/src/ 的后端源码整理,核对日 2026-08-10,对应仓库快照 c39c903。
本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,
因此不涉及界面外观、操作手感与切换速度的任何描述。
文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。
该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。