opencode2api
当一扇门被关上:opencode2api 诞生记
一个 Go 写的 OpenCode Zen / Go API 网关,以及它背后四十天的拉锯、补丁与重写。
引子:门是怎么关上的
写代码的人大多有这样的时刻:你在某个工具里用得顺手,手边的 Key 还有额度,模型也挑得正合心意,然后某天早上醒来,发现规则变了。
OpenCode 的 Zen 与 Zen Go 本来是一套不错的模型服务:一把 Key 就能调到一串模型,有些甚至免费。但它的 API 只允许在 OpenCode 自家的 agent 里使用。换句话说,你想在 Claude Code、Cursor、Cherry Studio,或者自己写的脚本里用它,都不行。
我并不想换编辑器,也不想换 agent。我习惯的工作流是:客户端说什么协议,我就给它什么协议;上游说什么协议,我就翻译成什么协议。 中间那层翻译,就是 opencode2api。
2026 年 8 月 14 日,第一个提交落地:
1 | f32c4a0 feat: add OpenCode Zen API compatibility proxy |
两千五百行,一个晚上。从那天起到今天,项目走过 64 次提交、约 11,000 行 Go 代码,来了 6 位贡献者。这篇文章想讲讲这段路。
第一章:一夜之间的原型
第一版非常朴素:gateway.go、pool.go、stream.go、models.go,平铺在根目录。目标只有一个:让任何 OpenAI 兼容客户端都能用上 Zen 的模型。
但”能跑”与”好用”之间,通常隔着一整个第一天。同一天里,我又连续提交了:
- 代理自动故障转移与健康检查:Key 挂在代理后面,代理坏了要能切走;
prefer字段:Zen 和 Go 两个 Tier 谁先谁后,交给用户决定;- Docker Compose 部署:开箱即用;
- 重写协议转发(
refactor: rewrite protocol forwarding):第一版的转发逻辑在当天就被推倒重来。
第二天,我撞上了这个项目真正的核心难题。
第二章:三种协议的巴别塔
如今的大模型 API 世界,基本被三种方言瓜分:
| 协议 | 路径 | 代表 |
|---|---|---|
| Chat Completions | /v1/chat/completions |
OpenAI 经典接口 |
| Responses | /v1/responses |
OpenAI 新一代接口 |
| Anthropic Messages | /v1/messages |
Claude 系列与 Claude Code |
麻烦在于,Zen 上的每个模型都有自己的原生协议:有的只说 Chat,有的只说 Responses,有的只说 Anthropic。而客户端也各有偏好,Claude Code 只说 Anthropic,很多工具只说 Chat。
于是网关必须做到 3 × 3 的任意互译,而且要同时覆盖普通 JSON 响应和 SSE 流式响应。
如果只是文本,这件事并不难。难在那些”不是文本”的东西:
- reasoning / thinking:Anthropic 的
thinking块带签名,Responses 的 reasoning 是独立 item,Chat 则各家有各家的字段; - 工具调用:Chat 的
tool_calls、Responses 的function_callitem、Anthropic 的tool_use内容块,三者结构、ID 规则、结束原因都不一样; - 工具结果与历史:多轮 agent 对话里,历史中的工具调用和 reasoning 必须原样回传,否则上游直接拒绝。
8 月 14 日到 15 日的四个提交,几乎都在和这件事搏斗:
1 | efbdb6e fix: normalize tool history and improve health checks |
最终的方案是:同协议直通,跨协议走统一中间结构。客户端协议与上游原生协议相同时,供应商特有字段原样保留;不同时,请求先解码成一个中立的中间表示,再按目标协议重新编码。这就是今天 internal/protocol/ 下 request.go、bridge.go、stream_parser.go、stream_emitter.go 的来历,其中光请求转换就超过一千行。
第三章:看不见的坑
协议转换最可怕的 bug,不是报错,而是安静地出错。
幽灵工具调用
有一类问题让我印象很深:上游的 Chat / Responses 回合有时会以 finish_reason: tool_calls 结束,却没有给出任何可用的工具调用,可能是空数组,也可能是缺了 name 的 delta。
网关如果老老实实把它翻译成 Anthropic 的 stop_reason: tool_use,就会出现一个很诡异的结果:客户端收到”我要调用工具”的信号,却找不到任何 tool_use 块。严格的客户端于是什么也不执行、什么也不报错,agent 就这样在任务中途静静停下。
修复本身只有几行:没有组装出任何工具块时,把 tool_use 降级为普通的 stop。但找到它,花了远比几行多得多的时间。
1 | 278b0bb fix: demote phantom tool_use stop when no tool block was assembled |
过期的 reasoning 引用
Responses 协议的 reasoning item 有生命周期。长会话里,客户端回传的 rs_... 可能在上游已经过期,于是得到一个 400:
1 | Referenced reasoning item 'rs_...' was not found or has expired |
网关现在会识别这类错误,剥掉 reasoning 输入项和 previous_response_id,换一个新的上游会话重放一次。其他 400 原样透传,不做多余的事。
缓存亲和性
还有一个更隐蔽的问题:原生 OpenCode 会发送 prompt_cache_key、safety_identifier、store、service_tier 这四个字段,而早期的转换器把它们全丢了。结果是,经过代理的对话永远吃不到 prompt cache。
补上以后实测:同一会话热身之后,上游缓存命中率达到 99%。对长上下文的 agent 来说,这直接关系到速度和账单。
第四章:猫鼠游戏
如果说协议转换是和技术较劲,那么接下来的部分,就是在和规则较劲。
匿名通道
8 月 17 日,项目加入了匿名 Zen 支持。OpenCode 客户端本身在未登录时使用一个共享的 public 凭证访问免费模型,网关也可以这样做。启用 anonymous: true 后,模型 ID 含 free,或在 models.dev 上输入输出成本均为零且未弃用的模型,会先尝试匿名通道,失败再回退到认证 Key。
上游开始”查户口”
9 月 17 日,上游开始拒绝那些 x-opencode-session 格式不对的免费请求,返回 403 FreeTierError。合法的会话 ID 长这样:
1 | // "ses_" + 12 位小写十六进制时间戳 + 14 位 Base62 字符 |
于是网关把所有下游会话标识确定性地映射成这个形状,原生会话则原样保留。同一个对话永远得到同一个 ID,亲和性不受影响。
“你看起来不像一个 agent”
仅仅一天之后,9 月 18 日,上游又加了一道门槛:免费请求必须看起来像真实的 agent 流量,也就是必须 stream: true,必须带着核心的 agent 工具定义:
1 | var anonymousCoreTools = []string{"bash", "edit", "glob", "grep", "read"} |
这对网关来说是个有意思的挑战:很多客户端要的是非流式 JSON,也根本没有这些工具。解决办法分两步:
- 塑形:发往上游的请求体强制开启流式,缺什么核心工具就注入什么最小定义;
- 坍缩:把上游返回的 SSE 流重新折叠成客户端要的那个完整 JSON 文档(
internal/protocol/collapse.go)。
客户端对这一切毫无感知,它发出一个普通请求,拿回一个普通响应。
第二天,这个规则又扩展到了认证 Key 通道上的免费模型,于是塑形逻辑也跟着覆盖过去。这次判断依据换成了目录里的价格元数据,而不是名字里的 -free,这样没有 free 后缀的免费模型也不会漏掉。
1 | 8185202 fix: use canonical OpenCode session IDs for Zen free tier |
这三个修复全部来自社区:前两个是 smile89 的 PR(#28、#31),第三个来自 sk-dev-ai。上游每改一次,就有人第一时间抓包、定位、提 PR。开源项目最动人的,大概就是这种时刻。
有时候,最好的修复是撤回
最近的两个提交很有意思:
1 | 7194c49 fix: drop unsupported tools for Mimo 2.6 Flash |
提交,然后撤回。为某一个模型写特判,看起来能立竿见影,但它会让网关越来越像一堆补丁。一个网关应该尽量如实地转换,而不是替上游和客户端做主。这次撤回,是一次关于边界的提醒。
第五章:给网关装上仪表盘
网关跑起来以后,我很快意识到一个问题:我看不见它在做什么。请求走了哪个 Tier?用了哪把 Key?哪个代理?是流中途断了,还是上游压根没响应?
8 月 16 日,项目加入了带认证的 Web 管理控制台;8 月 20 日,控制台经历了一次彻底的重新设计,同时加入了 Playground、路由诊断和 Token 统计。
Playground

Playground 可以选协议、选模型、选 Key,直接发一次真实的推理请求,并返回完整的路由信息:走了哪条通道、用了哪把 Key(以指纹显示)、上游返回了什么。
有一个设计我比较在意:诊断请求永远不会污染生产状态。无论是自动模式还是指定 Key 模式,都不会改变生产 Key 的冷却与失败计数,也不会改变代理的健康状态和绑定关系。指定 Key 模式下,诊断只做一次上游尝试,不进入匿名通道、不轮换 Key、不切换 Tier,返回的结果会明确告诉你这把 Key 是 usable、rejected、rate_limited 还是 transport_error。
它就是一个测 Key 的听诊器,而不是一次会改变系统的操作。
Token 用量

Token 统计只采信上游报告的 usage,绝不估算。输入 Token 包含缓存读取和写入,缓存读取量单独统计。一个请求如果上游没报 usage,就不计入,并通过”覆盖率”坦白地告诉你有多少请求是有数据的。
另一个原则是:请求结果以完整的推理过程为准。HTTP 200 发出去了,不代表请求成功。如果 SSE 中途出现错误事件,或者流异常断开,就会被计为失败并标记为 stream_error;客户端主动取消则标记为 client_canceled。只看状态码的监控,会在流式场景里骗你。
第六章:工程上的一些坚持
一个”绕过限制”的小工具,很容易写成一次性脚本。但我希望它能长期稳定地跑在别人的服务器上,所以在工程上做了一些坚持:
单文件交付。 WebUI 通过 embed 内嵌进可执行文件,运行时不需要 Node.js,不需要数据库。下载一个二进制,写一份配置,就能启动。
配置热重载,但先验证再切换。 新配置会先被完整校验并构建出一个新的 Gateway 实例,成功后才保存并切换;失败时旧实例继续服务,正在进行的请求也继续用旧实例跑完。
安全默认值。 WebUI 的初始明文密码在首次启动时会被 Argon2id 哈希化,并从配置文件与备份中删除明文;登录有限速,写操作有 CSRF 校验,Cookie 是 HttpOnly + SameSite。
Docker 容器最小权限。 普通用户运行、只读根文件系统、独立状态卷,镜像发布在 GHCR。
代理与会话亲和性。 Key 均衡绑定到代理,失败按指数冷却,最高到基数的八倍,并尊重上游的 Retry-After;异常代理每 15 分钟复查一次。同一会话尽量落在同一把 Key 上,这对 prompt cache 至关重要。
把代码组织好。 9 月 15 日的一次大重构,把平铺的源文件拆成了 cmd/ 与 internal/ 下的十个职责明确的包:admin、config、gateway、protocol、models、telemetry……README 里甚至写了一份”建议阅读顺序”,希望后来的人能顺着一条线读懂整个系统。
第七章:那些一起写代码的人
回头看提交记录,这个项目并不是一个人的独奏:
- Scotlight 修复了 Anthropic 请求中内联 system / developer 角色的问题(#8);
- Fance-rest 让 models.dev 元数据的拉取也走配置的代理(#14),这对网络受限的环境很重要;
- xiechen 贡献了过期 reasoning 引用的自动重试,以及那个让 agent 静静卡住的”幽灵工具调用”修复;
- sk-dev-ai 为
/v1/models补齐了完整的模型元数据(上下文窗口、推理能力、工具调用与多模态),找回了丢失的缓存亲和性字段,还把免费模型塑形扩展到了 Key 通道; - smile89 在上游规则变化时,接连送来了会话 ID 规范化和匿名通道 agent 塑形的修复(#28、#31);
- 还有更多提 issue、抓包、反馈的朋友。
也感谢 LINUX DO 社区。很多问题最早就是在那里被发现、被讨论的。
尾声
从 8 月 14 日的那两千五百行开始,opencode2api 变成了今天这样:
- 三种协议、JSON 与 SSE 的任意互译;
- Zen / Go 双 Tier、匿名通道、Key 池、代理池与会话亲和性;
- 内嵌的管理控制台、Playground、Token 统计与实时日志;
- 一个二进制文件,或者一行
docker compose up -d。
它的起点,是一扇被关上的门。但写着写着,我发现自己更在意的已经不只是”绕过去”,而是如何在两个本不相通的世界之间,做一个足够诚实的翻译者:不丢字段、不吞错误、不估算数字、不在用户看不见的地方偷偷改变状态。
如果你也在用 OpenCode 的 Key,却不想被绑定在某一个客户端上,欢迎试试:
1 | git clone https://github.com/jasonxu114514/opencode2api.git |
然后把你最喜欢的客户端指向 http://localhost:8080。
写在最后的提醒:opencode2api 是一个个人开源项目,与 OpenCode 官方无关。使用第三方客户端访问上游服务,可能不符合服务条款,上游也可能随时调整策略,导致请求被拒绝甚至账号受限。请自行评估风险,合理使用。
