我花了一点时间,做了一个 cc-router:把 Claude Code 接到任意 OpenAI 兼容模型上

  • King
  • 发布于 9小时前
  • 阅读 28

最近我做了一个小工具,叫cc-router。它解决的问题其实很具体:我想继续用ClaudeCode的体验,但后端模型和服务我想自己选。比如你已经有:OpenAI兼容网关OpenRoutervLLM公司内部代理或者你自己的模型服务这时候你会发现一件很现实的事:不是模型

最近我做了一个小工具,叫 cc-router

它解决的问题其实很具体:

我想继续用 Claude Code 的体验,但后端模型和服务我想自己选。

比如你已经有:

  • OpenAI 兼容网关
  • OpenRouter
  • vLLM
  • 公司内部代理
  • 或者你自己的模型服务

这时候你会发现一件很现实的事:

不是模型不能用,而是接口对不上。

Claude Code 这一侧期待的是 Anthropic 风格接口, 但很多现成服务提供的是 OpenAI-compatible 接口。

于是我做了一个本地小 router,专门干这个事:

前面接 Claude Code,后面接 OpenAI 兼容接口,中间负责把请求和响应翻译一下。

这篇文章不讲套话,我就按真实做项目的思路,聊聊我是怎么想、怎么拆、怎么避坑的。


先说人话:cc-router 到底是干嘛的?

一句话解释:

它是一个本地协议转换代理。

Claude Code 发请求给它, 它再把请求转成 OpenAI Chat Completions 的格式发到上游; 等上游回来了,它再翻译回 Claude Code 能认的格式。

所以它做的不是简单“转发”,而是:

  • 请求协议翻译
  • 响应结构翻译
  • 流式 SSE 事件重组
  • 模型名映射
  • 错误格式统一

你可以把它理解成一个“翻译官”。

前面说 Anthropic 这套话,后面说 OpenAI-compatible 那套话, cc-router 在中间负责把两边都哄明白。


我为什么会想做这个东西?

原因其实不复杂。

我很喜欢 Claude Code 这种工作方式。

它不是单纯聊天,而是真的能进工程流:

  • 读代码
  • 改文件
  • 跑命令
  • 帮你分析问题
  • 跟着上下文继续往下做

这种体验一旦顺手了,真的不太想换。

但问题是,现实世界里的模型后端并不总是那么统一。

你可能会遇到这些情况:

  • 公司统一走自己的 AI 网关
  • 你想切不同 provider 比效果和成本
  • 你已经有一套 OpenAI-compatible 接口
  • 你希望本地模型或私有部署也能接进来
  • 你不想把整个开发链路绑死在某一家服务上

这时候就很容易卡住。

因为你会发现:

工具体验和模型提供方,很多时候是强绑定的。

而我做 cc-router,本质上就是想把这件事拆开。

工具还是那个工具,后端归我自己决定。

这件事对工程师来说,其实挺有吸引力的。


真做之后我发现,这不是“代理”,是“协议适配器”

一开始很多人会觉得,这类工具不就是一个反向代理吗?

真不是。

普通代理解决的是:

请求从 A 转发到 B。

cc-router 要解决的是:

A 和 B 说的根本不是一种协议,你得在中间翻译。

比如看起来都是“聊天接口”,但细节差异一点也不少:

  • 请求 body 结构不同
  • system 的表达方式不同
  • messages.content 组织方式不同
  • 流式输出事件格式不同
  • token usage 字段不同
  • stop reason 语义也不同

所以这个项目最核心的地方,从来都不是 HTTP 转发。

而是:

协议映射。

这个认识非常重要。

因为一旦你把它当“协议翻译器”来设计,很多决策都会自然清晰:

  • 类型要不要单独建
  • 流式是不是能透传
  • 错误要不要统一格式
  • 模型映射该不该做成配置
  • 模块怎么拆才不容易失控

我一开始就提醒自己:别做成“大而全”的 AI 网关

这种项目特别容易自我膨胀。

最常见的心路历程是这样的:

“既然都做 router 了,那顺手把 tool use 做了吧。” “多模态以后肯定也要支持,不如现在先设计进去。” “JSON mode 和 structured outputs 一起做是不是更完整?” “provider-specific 兼容也先预留吧。”

结果就是:

第一版还没跑通,你已经先把自己设计崩了。

所以我这次刻意控制范围,只做最小闭环。

先完成下面这件事:

  1. 本地起服务
  2. 提供健康检查
  3. 提供 /v1/messages
  4. 接收 Anthropic 风格请求
  5. 转成 OpenAI 请求
  6. 请求上游
  7. 把结果转成 Anthropic 风格返回
  8. 支持非流式
  9. 再支持流式

你别小看这九步。

只要这条链路跑通,cc-router 就已经是个能工作的东西了。

很多项目最后做不成,不是技术上做不到,而是第一版想做太多,把问题空间一下子放大了。

所以如果你也想自己做一个类似的工具,我非常建议:

先做最短可用链路,不要一上来做全量兼容。


项目结构怎么拆,我的原则就一句话:按职责,不按感觉

这种中间层项目,如果目录乱了,后面真的会很痛苦。

我比较喜欢的结构大概是这样:

src/
├── main.rs
├── config.rs
├── server.rs
├── error.rs
├── routes/
│   ├── mod.rs
│   ├── health.rs
│   └── messages.rs
├── upstream/
│   ├── mod.rs
│   └── openai_compat.rs
└── types/
    ├── mod.rs
    ├── anthropic.rs
    └── openai.rs

这个结构不花哨,但够用,而且清楚。

config.rs

就专门管配置。

这种工具很多问题都不是业务 bug,而是配置错了。

所以像这些东西,都适合集中处理:

  • 监听地址
  • 上游 base URL
  • 上游 API key
  • 默认模型
  • 模型映射

这层单独拆出来,后面排查问题会轻松很多。

routes/messages.rs

负责接 Anthropic 风格请求。

注意,这层最好不要写成一个巨长 handler, 把“接请求、验参数、调上游、拼 SSE、处理错误”全堆在一起。

那样第一版写起来也许快,后面看就会想骂自己。

upstream/openai_compat.rs

这一层就是“上游适配器”。

专门负责:

  • 生成 OpenAI-compatible 请求
  • 调上游接口
  • 收上游响应
  • 处理流式和非流式两条路径

以后你想再接别的后端,基本也是从这里扩。

types/anthropic.rstypes/openai.rs

这层我很建议认真建。

很多人做这种项目,图快,全程 serde_json::Value 起飞。 短期很爽,长期很痛苦。

协议转换最怕的是“我以为这个字段一定有”。 有了明确类型,很多问题会更早暴露出来,也更容易做稳定映射。

我的经验是:

这类项目里,类型不是累赘,是安全带。


真正开干时,最重要的不是转发,而是模型映射

如果只让我选一个“必须从第一天就考虑清楚”的点,我会选 模型映射

因为 Claude Code 发来的模型名,通常是这种:

  • claude-4-6-sonnet
  • claude-4-6-opus

但你后端能识别的,往往是别的名字:

  • gpt-5.4
  • kimi-k2.5
  • glm-5
  • qwen3.5-plus

你不做映射,中间层根本就没法用。

最省心的方式,就是把映射做成配置,例如:

export CC_ROUTER_MODEL_MAP='{
  "claude-4-6-sonnet": "gpt-5.4",
  "claude-4-6-opus": "gpt-5.4"
}'

这样做的好处很直接:

  • 换上游不用改代码
  • 不同环境可以独立配置
  • 想临时切模型也方便
  • 多个 Anthropic 模型名甚至可以映射到同一个后端模型

为什么我不建议写死?

因为这种工具存在的意义,本来就是“解耦”。 如果模型映射还写死在代码里,那你每次切 provider 都要重新改、重新编译,体验就很别扭。

一句话:

部署环境会变的东西,尽量不要写死。


非流式不难,真正会让你掉头发的是流式 SSE

如果只做非流式,其实整体难度还比较正常。

流程基本就是:

  • 收到 Anthropic 请求
  • 转成 OpenAI 请求
  • 打上游
  • 收完整 JSON
  • 再转回 Anthropic 响应

这条路径虽然也要处理字段差异,但至少它是“完整响应 → 完整响应”,心智负担没那么高。

真正麻烦的地方,是 stream: true

因为这时候上游不是一次性给你结果, 而是源源不断给你 delta。

而 Anthropic 侧期待的 SSE 事件,大概是这种节奏:

  • message_start
  • content_block_start
  • content_block_delta
  • content_block_stop
  • message_delta
  • message_stop

问题在于,OpenAI-compatible 上游通常不是这套表达方式。

所以你不能简单把上游流原样转发回来。

你得在中间自己重组语义。

也就是说,你不是在“转字节流”,你是在“模拟一条 Anthropic 风格消息是怎么一点点生成出来的”。

这个事情最容易低估。

因为它看起来只是 SSE,实际上你在处理中间状态:

  • 什么时候发 message_start
  • 什么时候开第一个 content block
  • 每次 delta 怎么包
  • 什么时候结束 block
  • stop reason 什么时候补
  • usage 在哪个阶段发更合适

如果这些节奏没理顺,就算字最后都出来了,客户端体验也会怪。

所以我的建议很明确:

先把非流式走通,再做流式。

先支持纯文本 delta,再考虑复杂内容块。

别在 streaming 还没搞定的时候,就开始设计一个“未来支持万物”的超级事件状态机。

那很容易把自己做进去。


真正决定工具是否靠谱的,不是 happy path,而是 error path

这个我感触特别深。

很多项目成功路径都写得挺顺,一旦出错,就开始甩原始异常、吐 provider 自己的报错、返回格式前后不一致。

技术上也不是不能跑,但用起来很差。

因为如果你对外提供的是 Anthropic-compatible 接口, 那成功时像 Anthropic,失败时也应该尽量像 Anthropic。

比如错误返回至少应该统一成类似:

{
  "error": {
    "type": "api_error",
    "message": "upstream request failed: ..."
  }
}

为什么这件事很重要?

因为中间层不是单纯通一下就完了。 它应该帮上层“收敛复杂度”。

如果上游报错长什么样、语义是什么、状态码怎么解释,全都原样甩给客户端,那这个 router 的价值就打折了。

我一直觉得,一个中间层项目是不是像样,不看它最顺的那条路,看它报错时有没有人话。

成功大家都会,失败时还能不能让人快速定位问题,才看得出有没有认真做。


配置体验别搞太复杂,真的会劝退人

这类工具很容易越做配置越多。

最后变成作者看着很灵活,用户看着很窒息。

我自己的偏好是:配置项够用就行,不要追求“看起来特别全”。

最基础的几项,其实已经够覆盖主要场景:

export CC_ROUTER_UPSTREAM_BASE_URL="https://api.openai.com"
export CC_ROUTER_UPSTREAM_API_KEY="sk-..."
export CC_ROUTER_UPSTREAM_MODEL="gpt-5.4"
export CC_ROUTER_LISTEN_ADDR="127.0.0.1:8787"
export CC_ROUTER_MODEL_MAP='{"claude-4-6-sonnet":"gpt-5.4"}'

然后 Claude Code 只要指向本地:

export ANTHROPIC_BASE_URL="http://127.0.0.1:8787"
claude

这时候整条链路一眼就懂:

  • Claude Code 照旧工作
  • 本地 cc-router 接请求
  • cc-router 转给你指定的 OpenAI-compatible 上游

用户根本不需要理解你内部怎么组织模块,也不需要学一套新的复杂抽象。

一个好用的基础工具,真的不是靠配置项数量取胜的。

而是:

第一次上手就能跑起来。


我会优先盯住这三个接口,不会乱扩范围

如果从零再来一遍,我依然会先盯住这三个点:

1. GET /health

这个接口虽然简单,但非常值。

它的价值不在业务,而在排障。

很多时候问题根本不是协议转换,而是服务没起来、端口没监听、环境变量没读到。 有了 health check,你能更快判断问题落在哪一层。

2. POST /v1/messages 非流式

这是最短主链路。

只要这条通了,说明整体架子基本立住了。

Anthropic 请求 → 转换 → 上游调用 → 响应映射 → 返回

这条链一旦走通,你心里会踏实很多。

3. POST /v1/messages 流式

这是使用体验的关键。

没有流式,也不是不能用。 但只要你在命令行场景里用过一次,就会知道流式有多重要。

尤其是 Claude Code 这种交互工具, 用户对“有没有持续往外出字”是非常敏感的。


做这种东西,有几个坑真的很真实

坑一:别以为 content 永远只是字符串

这是最容易想当然的地方。

Anthropic 风格的 content 更像内容块数组; OpenAI-compatible 这边,不同 provider 兼容得也不完全一样。

所以哪怕第一版只支持 text-only, 也最好在建模时承认一个事实:

content 本质上是结构化内容,不是永远简单 string。

这不是过度设计,而是给后面留活路。

坑二:stop reason 不能直接抄

上游可能返回:

  • stop
  • length
  • content_filter
  • tool_calls

但 Anthropic 侧理解的是另一套语义,比如:

  • end_turn
  • max_tokens

这东西最烦的地方在于: 错了不一定直接报错,但行为会变怪。

这种“表面上能跑,实际上语义不对”的问题,比直接 500 还难受。

坑三:日志一定要是为了排障服务

我很建议从第一天就把日志打清楚,至少要能看到:

  • 服务监听地址
  • 上游 base URL
  • 当前请求是否 stream
  • 模型映射结果
  • 上游状态码
  • 错误摘要

因为你迟早会遇到这种情况:

“看起来都对,但就是没回。” 这时候日志如果只有一句 request failed,基本等于白打。


为什么我最后会用 Rust 来写?

说实话,这种工具不用 Rust 也能做。

Go 可以,Node.js 也可以。 如果只是验证 idea,脚本语言起手完全合理。

我最后用 Rust,不是为了“性能吹牛”,而是因为它确实适合这种场景:

第一,类型系统对协议转换很有帮助

这种项目的核心,不是业务逻辑有多复杂,而是协议边界很多。

Rust 会逼你把字段、状态和边界条件想清楚。 这对中间层项目是好事。

第二,做异步和流式处理更安心

SSE、异步 HTTP、状态维护,这些东西写起来虽然没有脚本语言那么随手,但边界会更清楚。

第三,最后可以落成单二进制

本地工具做成单 binary,体验真的很好。

拿来就跑,不折腾运行时环境,这点在工具型项目里很加分。

当然,语言只是实现手段。 别把“选型”变成拖延的理由。

真正重要的还是那句老话:

先把最短可用路径做出来。


如果让我再做一次,我会坚持这几个原则

做完回头看,我觉得有几条原则特别值得保留。

1. 第一版先只做 text-only

不要上来就碰多模态、tool use、structured outputs。

先把聊天主链路做稳,这已经够难了。

2. 内部先有统一表示,再做双向转换

不要在 handler 里直接把 Anthropic 请求手搓成 OpenAI 请求。

最好先转成一套内部统一结构,再分别适配上下游。 这样以后你想接第二种 provider,不至于全拆。

3. 对外兼容,对内简单

外部协议可以复杂,内部流转尽量保持清晰。

否则最后你不是做了一个 router, 而是做了一团谁都不想碰的胶水层。

4. 别为“未来可能支持”提前设计太多

这是特别容易掉进去的坑。

很多项目不是做不出来,而是提前抽象太多,把自己先绕晕了。


一个最小可用的 cc-router,我觉得做到这些就够发第一版了

如果只是出第一版,我觉得做到下面这些就已经很不错:

  • 支持监听地址配置
  • 支持上游 URL / API key / 默认模型配置
  • 支持模型映射
  • 提供 GET /health
  • 提供 POST /v1/messages
  • 支持非流式文本请求
  • 支持流式文本请求
  • 支持基础错误映射
  • 支持基础 usage 映射
  • 有可排障的结构化日志

而下面这些,我会明确放到后续版本:

  • tool use / function calling
  • 多模态
  • JSON mode
  • structured outputs
  • 更复杂的内容块兼容
  • provider-specific 深度适配

不是不能做,而是没必要在第一版一起做。

基础设施项目最忌讳的,就是“看起来很全,但核心链路并不稳”。


最后我越来越觉得,这类工具真正有价值的地方,是“解绑”

表面上看,cc-router 只是一个小代理。

但从工程角度看,它真正做的事情其实是:

把工具体验和模型后端拆开。

一旦中间有了这样一层 router,你能做的事情就一下子多了很多:

  • 前端继续用熟悉的 Claude Code
  • 后端模型可以自由换
  • 可以走统一网关,也可以走本地推理
  • 可以加自己的鉴权、审计、路由策略
  • 可以把“客户端工作流”和“模型供应商”解耦

我觉得这才是最有意思的地方。

不是“我又造了一个轮子”, 而是:

原来绑死的一条链路,被拆开了。

这种能力,对工程师来说通常比多一个 feature 更值钱。


结尾

如果你也想自己做一个 cc-router,我的建议就一句话:

别一上来把它当成一个大而全的 AI 网关。先把它当成一个只解决眼前问题的协议翻译器。

先把消息链路跑通, 先把流式做稳, 先把错误处理做好, 先让 Claude Code 真能接上你自己的后端。

剩下的能力,完全可以慢慢补。

因为大多数时候,真正难的不是写代码。

而是你能不能忍住,不在第一版里把自己做复杂。

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
King
King
0x56af...a0dd
擅长Rust/Solidity/FunC/Move开发