从 0 到 1:用纯 Go 标准库写一个高可用的 AI 协议网关(opencode2api)

项目地址:github.com/jasonxu114514/opencode2api
一个 Go 编写的 OpenCode Zen / Zen Go 协议代理,对外提供标准 OpenAI 与 Anthropic API,自动添加 OpenCode 客户端请求头。
零第三方依赖、纯标准库、约 3900 行、Docker Compose 一键部署。


一、为什么写这个项目

AI 生态里协议是分裂的。OpenAI 有 Chat Completions 和 Responses 两套 API,Anthropic 有自己的 Messages API,而 OpenCode 客户端走的是自定义的 zen / zen/go 协议,鉴权方式、请求头、甚至会话语义都和别人不一样。

这意味着:

  • Claude Code、Cline、Continue 等一堆 OpenAI/Anthropic 兼容工具没法直接连上 OpenCode 的模型服务;
  • OpenCode 的 Zen 与 Zen Go 是两个不同的「档位」,模型、key、价格体系各异,用起来要自己分;
  • 海外代理、多 key 分发、故障容错,这些本来不该让使用方操心。

所以我想做一个胶水层:对外暴露最通用的 OpenAI / Anthropic API,对内负责把协议翻译成 OpenCode 听得懂的语言,并顺手把多 key、多代理、故障转移这些脏活接过去。

于是 opencode2api 诞生了。整件事我从第一个 commit 到最后一版修复,花了大约 19 个小时。

如果你也被这个问题困扰过,欢迎到 GitHub 点个 Star、提 issue。


二、架构一览

1
2
3
4
5
6
7
8
9
10
11
12
13
14
┌─────────────┐    OpenAI Chat/Responses / Anthropic Messages
│ 客户端工具 │──────────────────────────▶│ 鉴权(server_keys)
└─────────────┘ │ 协议识别+路由(Route)
│ 请求桥转换(convert.go)

┌─────────────────┐
│ opencode2api │
└─────────────────┘

模型目录(modelCatalog): zen 池 / go 池 ──prefer──▶ 选档

key/代理池(nodePool): 轮询绑定 → 亲和哈希 → 冷却/退避

transports ←─[direct | http(s) | socks5(h)]──▶ 上游

关键设计

关注点 实现
协议 chat / responses / anthropic 双向互转,内部统一走 bridge 中间表示
模型路由 inferProtocol 按模型名前缀推断协议,models.protocols 可手动覆盖;双池模型按 prefer 选档
高可用 多 key 轮询 + 多代理绑定,故障自动迁移,冷却指数退避,15 分钟巡检
会话 FNV 哈希做会话亲和,同一对话稳定绑定同一上游 key
兼容 thinking/reasoning 历史按目标协议规范化(针对 DeepSeek/Kimi/MiMo 兼容端点)
流式 SSE 逐帧转码,支持工具调用的增量参数、reasoning signature
运维 /healthz 健康检查,Docker Compose 部署,启动时自动构建缓存二进制

三、核心难点:三协议互转的「桥」

三种协议虽然都在讲「消息 + 工具 + 流式」,但字段语义差异很大:

  • Chatmessages[] + delta.tool_calls(分片、靠 index 归并),reasoning 走非标准的 reasoning_content
  • Responsesinput[] 是一个顺序数组,function_call / function_call_output 互相配对,工具参数是流式 arguments
  • Anthropiccontent 是一组块(block)tool_use / tool_resultblock id 关联,多一个 thinking 块和 signature 字段。

关键决策是不写两两转换成 6 个方向,而是建一个内部的「桥」中间表示:

1
2
3
4
5
bridgeRequest / bridgeResponse / bridgeBlock
▲ ▲
│ decode │ encode
│ │
chat ◀──┼── responses ──┼── anthropic
1
2
3
4
5
6
7
8
9
10
// convert.go 中统一的请求准备路径:同协议走 clone,跨协议走 bridge,
// 之后再统一做目标协议的历史规范化。
func prepareUpstreamRequest(from, to Protocol, input map[string]any, upstreamURL string) (map[string]any, error) {
output, err := convertRequest(from, to, input)
if err != nil {
return nil, err
}
normalizeToolReasoningHistory(to, stringAt(output, "model"), upstreamURL, output)
return output, nil
}

这样对外暴露什么协议、对内走什么协议,解耦成两件独立的事。4d10eac refactor: rewrite protocol forwarding 这次重构就是把它从「两套分支」合并成这一条统一路径——这是我在开发中踩坑后得出的最重要的一条架构经验,详情见第五节。


四、开发历程(Git 时间线)

整个项目从首提交到修复收官,历时约 19 小时,11 个提交。节奏大概是:先把骨架搭出来跑通 → 疯狂补高可用 → 大重构 → 磨兼容性。

1
2
3
4
5
6
7
8
9
10
11
2026-08-14  08:33  f32c4a0  feat: add OpenCode Zen API compatibility proxy        ← 首版,~2500 行
2026-08-14 09:37 1f603c3 feat: add automatic proxy failover and health checks ← 故障转移
2026-08-14 10:10 e220e7a fix: refine proxy health recovery ← 健康恢复
2026-08-14 10:34 adb0edb config: make zen/go preference configurable ← prefer 选档
2026-08-14 11:02 a2a58e1 feat: add Docker Compose deployment ← 部署
2026-08-14 11:04 66e1ef1 Fix clone command and improve Docker instructions
2026-08-14 16:16 efbdb6e fix: normalize tool history and improve health checks
2026-08-14 16:57 4d10eac refactor: rewrite protocol forwarding ← 大幅重构桥
2026-08-14 17:18 52f19b7 fix: preserve reasoning and add session affinity ← 会话亲和
2026-08-15 02:52 1c149e3 fix: normalize Anthropic tool thinking history
2026-08-15 03:23 e68a0b9 fix: normalize tool reasoning history across protocols

技术栈上,go.mod 里只有两行——module opencode2apigo 1.24整个项目零第三方依赖,HTTP 服务器、SSE 解析、JSON 编解码、SHA/FNV 哈希、socks5 代理全部用标准库。好处是编译快、二进制干净、docker-entrypoint.sh 里甚至在容器内 go build 都很轻量;坏处是标准库不带代理协议实现,socks5 我用了 Go 自带支持(http.Transport.Proxy = http.ProxyURL),够用。


五、踩过的坑(最有价值的部分)

5.1 代理故障的误判让「健康检查」形同虚设

一开始我把代理可用性交给定时健康检查,结果发现两个问题:误伤反应慢

  • 请求 401/403/429 甚至 5xx,不代表代理死了,但早期逻辑可能把 key 一并拉黑、把代理标记宕机;
  • 定时检查是滞后信号,等检测到已经损失了一些请求。

最终收敛成一套组合拳(pool.go / gateway.go):

  1. 极窄的故障定义isProxyFailure 只认超时和连接拒绝(ECONNREFUSED / DeadlineExceeded),其他 HTTP 状态一律不算代理故障:
1
2
3
4
5
6
7
8
9
10
func isProxyFailure(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, syscall.ECONNREFUSED) {
return true
}
var timeout interface{ Timeout() bool }
return errors.As(err, &timeout) && timeout.Timeout()
}
  1. 用真实流量当反馈源:每次上游请求结束都会 syncProxyResult,成功就把代理标记恢复;
  2. 异步复核:真实请求报错后,用 Cloudflare 的 https://cloudflare.com/cdn-cgi/trace 独立复核代理连通性(带 CompareAndSwap 防重入),每 15 分钟巡检一次未恢复的代理;
  3. 原子换绑:代理宕机时,把绑定在它上面的 key 按「最少负载优先」迁移到健康代理,恢复后原路归还(RestoreProxy),迁移过程对调用方透明。

5.2 流式响应中途切 key = 拼接两份不同的回答

多 key 重试原本的逻辑是「失败就换下一个 key 重来」。对流式响应这是灾难——如果已经向客户端吐出了几百字节,中途换 key 重跑,客户端收到的就是两个模型拼出来的怪东西

处理原则写得很明确(gateway.go):

流式响应一旦已经向客户端输出数据,就不会切换节点重新生成,避免拼接两个不同的响应。

所以在 doUpstream 里,只要成功开始返回 2xx 并进入流式拷贝/转码,就一条路走到黑;只有未输出任何字节的情况下才允许重试。这是流式网关最容易忽略的一条红线。

5.3 Thinking 历史:DeepSeek/Kimi/MiMo 兼容端点的「显式要求」

这是最隐蔽的坑,花了两个 commit 才收干净。

工具调用的多轮对话里,assistant 的消息要连同它的推理过程一起回放给模型。但不同客户端丢的东西不一样:

  • 有些客户端丢掉了 reasoning_content 但保留 tool_calls,导致下一次 thinking 模式的请求不合法(Chat 协议);
  • Anthropic 协议的 thinking 块合法形态包括signatureredacted_thinking、甚至空 thinking,但 DeepSeek/Kimi/Moonshot/MiMo 的兼容端点对包含 tool_use 的 assistant 轮一律拒绝这些合法形态。

所以:

  1. Chat 侧:给每个带 tool_calls 的 assistant 轮补齐 reasoning_content,有历史 reasoning 则提升复用,否则兜底占位;
  2. Anthropic 侧:保留有效 thinking 文本、剥离 signature、把 redacted_thinking 转回 thinking、缺失则插入占位,并插入 thinking 块;
  3. 激活条件要克制:只有模型名/上游 URL 命中推理厂商标识,或请求显式开启了 reasoning 时才做规范化,普通请求一条历史都不动——避免给不需要的厂商加戏导致行为漂移。

最新 commit e68a0b9 fix: normalize tool reasoning history across protocols 把规范化从客户端协议解耦,统一到「目标协议」上,让从哪个口进来不再影响发给上游的请求形态。

5.4 会话亲和:让多轮对话「说得上话」

模型(尤其 thinking 模型)的上下文状态往往和上游的某个 key 有黏性。如果同一段对话每次请求都随机落到不同的 key / 代理上,轻则体验割裂,重则直接报错。

做法(ids.go + pool.go):

  • 会话 ID 优先级:x-opencode-sessionx-session-idconversation-id → body 里的 conversation_id → 第一条用户消息的稳定哈希;
  • 上游 key 的选择用 CursorFor(affinity):对会话 ID 做 FNV-1a 哈希得到稳定起点,从起点开始轮询,同时保留冷却感知的故障转移能力;
  • 每个请求的 x-opencode-request 每次随机、重试保持不变,让上游能正确识别「这是同一请求的重试」。
1
2
3
4
5
6
7
8
func (p *nodePool) CursorFor(affinity string) nodeCursor {
if affinity == "" || len(p.nodes) == 0 {
return p.Cursor()
}
hash := fnv.New64a()
_, _ = hash.Write([]byte(affinity))
return nodeCursor{pool: p, next: int(hash.Sum64() % uint64(len(p.nodes)))}
}

5.5 重试策略:别拿所有 key 为一个确定性错误陪葬

retry.max_attempts 控制重试上限,但不是所有错误都值得换 key

  • 网络错误、401/403 认证失败、429 限流、5xx → 轮换节点重试;
  • 其他 4xx 是确定性的请求错误(比如参数非法),换多少 key 都是同一个结果 → 直接返回错给客户端;
  • Retry-After 响应头会被解析并纳入冷却时长;
  • 冷却时间按失败次数指数退避,以 1 << min(failures-1, 3) 封顶到 8 倍基础冷却。

5.6 没有文档:好的代理永远是个「中间人」

匿名背后还有个更根本的问题:OpenCode 的 zen / zen/go 协议没有公开的完整文档。你甚至不能先按 doc 实现,再对拍验证——第一次跑通基本只能靠「对着客户端的真实行为逆向」。

这个项目里你能看出一套典型的逆向痕迹(ids.go):

  • User-Agent 直接固化成 opencode/1.18.18 (...),等于在说「你在跟 1.18.18 版的客户端对话」;
  • x-opencode-session / x-opencode-request / x-opencode-client: cli / x-opencode-project 这组头,语义是从真实客户端的请求里一步步拼出来的;
  • 会话 ID 的推导优先级(x-opencode-sessionx-session-id → … → 第一条用户消息哈希)是在一次一次 4xx 试错中补全的「保底链」。

开发节奏上也存在诡异的留白66e1ef1(11:04,Docker 部署可用)到 efbdb6e(16:16)之间有约五个小时的提交空白,之后连续五个 commit 全部是 tool/reasoning 历史、thinking 规范化、「健康检查」类修复。合理推测:项目能部署、能对外提供服务之后,真实使用把一批只在长对话里才会暴露的怪问题冲刷了出来——多轮工具调用 + thinking 回放、代理误伤、流式拼接。没有线上反馈的磨炼,这些东西几乎不可能自己提前想到。

如果你的目标也是「兼容某个未公开协议的商业服务」,建议把客户端真实请求抓包 → 内部桥模型 → 用假上游做回归测试这条流水线尽早立起来。

5.7 健康检查端点本身也不能「大嘴巴」

/healthz 暴露的是给 Docker healthcheck、监控用的,所以:

  • 模型目录未刷新 / 过期 / 无模型 / 无健康代理时返回 503,否则 200,语义严格对应;
  • 状态里只有数量(key 数、代理健康数、模型数),绝不泄漏 key 或代理地址
  • 代码里大量使用 redactURL 脱敏代理 URL 中的用户名密码,日志也从不打印完整 key 和请求正文。

安全上还有两处细节:server_keys 鉴权用 subtle.ConstantTimeCompare常量时间比较防时序侧信道;请求体限制 32MB,响应体限制 64MB。


六、运维与部署

直接编译(需要 Go 1.24+)

1
go build -o opencode2api ./

Docker Compose 一把梭

1
2
3
4
5
6
7
8
9
10
git clone https://github.com/jasonxu114514/opencode2api.git
cd opencode2api
docker compose up -d

cp config.example.json config.json
# 编辑 config.json 填入 server_keys / zen_keys / go_keys
docker compose restart

curl http://127.0.0.1:8080/healthz
docker compose logs -f

容器入口脚本做了一个很聪明的缓存:对源码目录做 SHA-256 指纹,指纹没变就直接跑缓存二进制,变了才重新构建,并把构建产物存进持久化 volume。所以改 *.go 重启镜像,Go 的 Docker 构建缓存基本秒级命中,迭代体验很好。

修改宿主机端口:

1
OPENCODE2API_PORT=18080 docker compose up -d

支持的最小配置

1
2
3
4
5
6
7
8
9
10
11
12
{
"listen": "127.0.0.1:8080",
"server_keys": ["change-this-local-key"],
"zen_keys": ["sk-your-zen-key"],
"go_keys": [],
"prefer": "go",
"proxies": ["direct"],
"upstream": {
"zen": "https://opencode.ai/zen",
"go": "https://opencode.ai/zen/go"
}
}

代理支持 directhttp://https://socks5://socks5h://,可配账号密码,比如 socks5://user:pass@127.0.0.1:1080


七、GitHub 地址与下一步

项目在这里 👉 https://github.com/jasonxu114514/opencode2api

如果你正在用 Cli 类工具、Agent 框架,或者维护自己的 API 中转服务,希望多档位模型池、多代理容错和协议兼容这些能力开箱即用,欢迎:

  • ⭐ Star 支持一下,
  • 🐛 报 bug / 提需求,
  • 🛠 亲手来加 models.protocols 覆盖、新协议适配。

下一步可能的方向:Prometheus 指标、流式 token 配额统计、多租户 key 限流、更多上游协议(比如兼容 gpt-* 别名模型的鉴权透传)…… 想法很多,欢迎一起来。