本文记录一个真实可跑的协议桥实现:拦截 Claude Code 的
/v1/messages请求,转交给 ZCode 的 headless app-server,再把流式事件翻译回 Anthropic SSE。全部基于 307 行 Node.js 源码,无第三方 HTTP 依赖。文末总结了 5 个实测踩过的坑。
为什么需要这座桥
先说清楚动机——不是为了"用国产模型",而是额度经济学。Claude Code 是目前最顺手的终端 AI 编程工具,但直接用它只能消耗 Anthropic 官方套餐。而把它的请求桥接到 ZCode 的 Coding Max 套餐通道后,请求按 ZCode 的额度扣率计费——以我手上的套餐实测,抵扣系数约 0.67,同样额度的有效用量约等于放大到 1.5 倍(具体扣率以官方当前政策为准)。顺带的收益是模型本身也换成了 GLM 系列,对日常编码场景完全够用。
但两者协议完全不同:
- Anthropic 侧:一次性 HTTP POST,回来的是
message_start → content_block_delta* → message_stop这套 SSE 事件; - ZCode 侧:要先
session/create建会话,session/send发内容,然后从事件流里订阅model.streaming、turn.completed等事件。
缺的就是中间的翻译层。先交代两个名词:ACP(Agent Client Protocol,Agent 客户端协议)是一套让编码 Agent 前端与后端模型服务解耦的 JSON-RPC 协议,zcode-acp-server是它的 Node 实现;ZCode 的 headless 模式叫app-server,本文的桥就是"Anthropic Messages 协议 → ACP 后端"的翻译官。
核心思路一句话:本地起一个只听 127.0.0.1 的 HTTP 服务,把 Anthropic 协议的请求翻给 ZCode,把 ZCode 的事件流翻回 Anthropic SSE。
架构:三段式
Claude Code ──HTTP/SSE──> 桥接服务(127.0.0.1:8080) ──JSON-RPC──> zcode app-server │ ├─ 1. 收 /v1/messages,压成 prompt 文本 ├─ 2. session/create(mode=yolo)+ subscribe ├─ 3. session/send 送入 prompt └─ 4. 轮询事件流 → 翻译成 Anthropic SSE桥接复用了现成的zcode-acp-server构建产物(ZcodeBackend/EventStreamListener/ 凭证加载),自己只写协议转换——这是它只有 307 行的原因。
难点一:app-server 会"反向找你要东西"
最隐蔽的坑在这里:session/create过程中,app-server 会反过来向桥接发请求(server→client 方向),其中:
// zod .strict() 对象:nativeSearchEnhancementsEnabled 必填 booleanb.sendReply(req.id,{nativeSearchEnhancementsEnabled:true,memoryEnabled:false,askUserQuestionAutoResolutionEnabled:true,});session/requestRuntimePreferences用的是 zod.strict()校验——少一个必填字段就直接报错,而你不回复它,session/create就永远挂起,表现为一个干巴巴的 timeout,日志里毫无线索。
解法是一个 100ms 轮询的drainer(排水泵):后台持续pollServerRequests(),把反向请求分类回复——运行时偏好回默认值,interaction/requestPermission回allow(yolo 模式免得工具调用卡住),interaction/requestUserInput回decline(headless 场景没人能答题),未知方法一律回空对象兜底。
这个模式可以推广:任何"客户端SDK里嵌着服务端回调"的协议,对接时第一件事就是把回调通道接住,否则主流程必然莫名超时。
难点二:两套流式协议的对齐
Anthropic 的 SSE 是严格的事件序列,少一环客户端就报错:
message_start → content_block_start → content_block_delta* → content_block_stop → message_delta(含 stop_reason 和 usage) → message_stop而 ZCode 侧给的是model.streaming(payload.kind=text_delta)+turn.completed/failed。翻译循环的关键细节:
- 先发头再发送:
session/send之前就先把message_start/content_block_start写出去——否则可能丢掉早到的turn.completed; - 轮询 + 心跳:
pollEvent(1000)循环里维护lastProgress,超过TURN_TIMEOUT_MS(默认 180 秒)没进展就按超时收尾; - stop_reason 映射:
turn.completed → end_turn,超时 →max_turn_requests,turn.failed→ 把错误文本塞进 delta 再正常收尾(客户端不会因异常断流); - usage 编造:ZCode 不回 token 数,用
output_chars / 4估算——SSE 里 usage 字段必须有,数值不准但协议合法。
难点三:多轮历史的降维
Anthropic 请求里是结构化的messages[](可能还带 tool_use/tool_result 块),而 ZCode 每次create都是全新 session。桥接的处理是把历史压平成一段文本:
functionbuildPrompt(request){// [system]\n... \n\n[user]\n... \n\n[assistant]\n...}文本块类型的 content 直接拼接,非文本块(图片等)丢弃。这是有损压缩,但对"用 Claude Code 干活"这个场景够用——因为真正需要长上下文的是 ZCode 自己的 session 内工具调用,历史 prompt 只是开场白。
五个实测坑(都是拿报错换来的)
ANTHROPIC_BASE_URL千万别带/v1后缀。Claude Code 会自动拼/v1/messages,你写成…:8080/v1就变成/v1/v1/messages→ 404。写http://127.0.0.1:8080就好;- Node 必须 ≥22:底层
zcode.cjs依赖内置的node:sqlite,低版本直接起不来; - 只监听 127.0.0.1 并校验 remoteAddress:桥接是本地代理性质,对外开放等于把你的模型配额变成公共 API;
- ZCode 重启后必须重启桥接:桥接 fork 的 app-server 子进程会跟着断,而且平台刷新后的 provider key 也要重新读取;
mode=yolo的边界:工具权限自动放行必须配 workspace 白名单,桥接日志里明确打了警告——自动化的代价是把安全决策交给作用域限制,别把 workspace 设成整个家目录。
收尾
做完这个桥的最大体会:AI 工具的互操作性问题,本质是"事件流方向"和"状态机生命周期"两类问题。反向请求要有人接(方向问题),session 要有人管(生命周期问题)——这两件事解决了,剩下的只是字段名对齐的体力活。
完整源码 307 行,欢迎在评论区交流你对接过的其他 AI 工具协议。