当一扇门被关上:opencode2api 诞生记

一个 Go 写的 OpenCode Zen / Go API 网关,以及它背后四十天的拉锯、补丁与重写。

项目地址:github.com/jasonxu114514/opencode2api


引子:门是怎么关上的

写代码的人大多有这样的时刻:你在某个工具里用得顺手,手边的 Key 还有额度,模型也挑得正合心意,然后某天早上醒来,发现规则变了。

OpenCode 的 Zen 与 Zen Go 本来是一套不错的模型服务:一把 Key 就能调到一串模型,有些甚至免费。但它的 API 只允许在 OpenCode 自家的 agent 里使用。换句话说,你想在 Claude Code、Cursor、Cherry Studio,或者自己写的脚本里用它,都不行。

我并不想换编辑器,也不想换 agent。我习惯的工作流是:客户端说什么协议,我就给它什么协议;上游说什么协议,我就翻译成什么协议。 中间那层翻译,就是 opencode2api。

2026 年 8 月 14 日,第一个提交落地:

1
2
f32c4a0 feat: add OpenCode Zen API compatibility proxy
12 files changed, 2499 insertions(+)

两千五百行,一个晚上。从那天起到今天,项目走过 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_call item、Anthropic 的 tool_use 内容块,三者结构、ID 规则、结束原因都不一样;
  • 工具结果与历史:多轮 agent 对话里,历史中的工具调用和 reasoning 必须原样回传,否则上游直接拒绝。

8 月 14 日到 15 日的四个提交,几乎都在和这件事搏斗:

1
2
3
4
efbdb6e fix: normalize tool history and improve health checks
52f19b7 fix: preserve reasoning and add session affinity
1c149e3 fix: normalize Anthropic tool thinking history
e68a0b9 fix: normalize tool reasoning history across protocols

最终的方案是:同协议直通,跨协议走统一中间结构。客户端协议与上游原生协议相同时,供应商特有字段原样保留;不同时,请求先解码成一个中立的中间表示,再按目标协议重新编码。这就是今天 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
2
278b0bb fix: demote phantom tool_use stop when no tool block was assembled
82e88f2 fix: filter phantom non-stream tool blocks

过期的 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
2
// "ses_" + 12 位小写十六进制时间戳 + 14 位 Base62 字符
var canonicalSessionPattern = regexp.MustCompile(`^ses_[0-9a-f]{12}[0-9A-Za-z]{14}$`)

于是网关把所有下游会话标识确定性地映射成这个形状,原生会话则原样保留。同一个对话永远得到同一个 ID,亲和性不受影响。

“你看起来不像一个 agent”

仅仅一天之后,9 月 18 日,上游又加了一道门槛:免费请求必须看起来像真实的 agent 流量,也就是必须 stream: true,必须带着核心的 agent 工具定义:

1
var anonymousCoreTools = []string{"bash", "edit", "glob", "grep", "read"}

这对网关来说是个有意思的挑战:很多客户端要的是非流式 JSON,也根本没有这些工具。解决办法分两步:

  1. 塑形:发往上游的请求体强制开启流式,缺什么核心工具就注入什么最小定义;
  2. 坍缩:把上游返回的 SSE 流重新折叠成客户端要的那个完整 JSON 文档(internal/protocol/collapse.go)。

客户端对这一切毫无感知,它发出一个普通请求,拿回一个普通响应。

第二天,这个规则又扩展到了认证 Key 通道上的免费模型,于是塑形逻辑也跟着覆盖过去。这次判断依据换成了目录里的价格元数据,而不是名字里的 -free,这样没有 free 后缀的免费模型也不会漏掉。

1
2
3
8185202 fix: use canonical OpenCode session IDs for Zen free tier
2ce1f9e fix: serve anonymous free tier as agent-shaped streams
79378c8 fix: shape key-tier free-model bodies to agent shape

这三个修复全部来自社区:前两个是 smile89 的 PR(#28、#31),第三个来自 sk-dev-ai。上游每改一次,就有人第一时间抓包、定位、提 PR。开源项目最动人的,大概就是这种时刻。

有时候,最好的修复是撤回

最近的两个提交很有意思:

1
2
7194c49 fix: drop unsupported tools for Mimo 2.6 Flash
c75a792 Revert "fix: drop unsupported tools for Mimo 2.6 Flash"

提交,然后撤回。为某一个模型写特判,看起来能立竿见影,但它会让网关越来越像一堆补丁。一个网关应该尽量如实地转换,而不是替上游和客户端做主。这次撤回,是一次关于边界的提醒。


第五章:给网关装上仪表盘

网关跑起来以后,我很快意识到一个问题:我看不见它在做什么。请求走了哪个 Tier?用了哪把 Key?哪个代理?是流中途断了,还是上游压根没响应?

8 月 16 日,项目加入了带认证的 Web 管理控制台;8 月 20 日,控制台经历了一次彻底的重新设计,同时加入了 Playground、路由诊断和 Token 统计。

Playground

Playground

Playground 可以选协议、选模型、选 Key,直接发一次真实的推理请求,并返回完整的路由信息:走了哪条通道、用了哪把 Key(以指纹显示)、上游返回了什么。

有一个设计我比较在意:诊断请求永远不会污染生产状态。无论是自动模式还是指定 Key 模式,都不会改变生产 Key 的冷却与失败计数,也不会改变代理的健康状态和绑定关系。指定 Key 模式下,诊断只做一次上游尝试,不进入匿名通道、不轮换 Key、不切换 Tier,返回的结果会明确告诉你这把 Key 是 usable、rejected、rate_limited 还是 transport_error。

它就是一个测 Key 的听诊器,而不是一次会改变系统的操作。

Token 用量

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
2
3
4
5
git clone https://github.com/jasonxu114514/opencode2api.git
cd opencode2api
cp config.example.json config.json
# 填好 server_keys、zen_keys / go_keys 和 webui.password
docker compose up -d

然后把你最喜欢的客户端指向 http://localhost:8080。


写在最后的提醒:opencode2api 是一个个人开源项目,与 OpenCode 官方无关。使用第三方客户端访问上游服务,可能不符合服务条款,上游也可能随时调整策略,导致请求被拒绝甚至账号受限。请自行评估风险,合理使用。