fix(adapter): send x-opencode-* identity headers so zen stops returning empty tool-call replies

zen fingerprints clients via UA + x-opencode-client/session/request/project
headers; requests without them are routed as anonymous and fail with a
single-chunk network_error stream (tools+stream) or 503 Endpoint is
unavailable (non-stream), which the adapter laundered into empty-but-valid
replies. Derive per-request session/request ids from build_headers meta
(sandbox has no os/math), bump UA to the real client format, map zen
reasoning field to reasoning_content, and set stream_options.include_usage.
This commit is contained in:
JianFeeeee
2026-08-24 14:37:09 +08:00
parent 690f55c6f1
commit 9c99ded8c0
2 changed files with 56 additions and 12 deletions

View File

@ -4,14 +4,34 @@ adapter.name = "opencode"
adapter.version = "1.0.0"
adapter.endpoint = "/chat/completions"
-- opencode.ai zen 网关按 User-Agent 指纹识别官方客户端并把请求分到免费额度池;
-- 非官方 UAcurl/Go 默认等)会被分到匿名池并触发 FreeUsageLimitError。
-- 因此固定发送 opencode 客户端的 UA配合源配置 api_key: "public"(官方无 key
-- 客户端实际发送 Bearer public即可走免费池。
-- opencode.ai zen 网关按客户端指纹UA + x-opencode-* 头)路由请求池:
-- 缺少 x-opencode-client/session/request/project 身份头的请求会被判为匿名
-- 客户端x-preview-f-free 等模型在带 tools 时上游直接失败——流式返回单
-- chunk "finish_reason":"network_error" 空 content非流式返回 503
-- "Endpoint is unavailable",表现为"空回复"。因此除 UA 外必须附带
-- x-opencode-* 身份头session/request 每请求派生唯一值(见 build_headers
adapter.headers = {
["User-Agent"] = "opencode/0.1.0",
["User-Agent"] = "opencode/1.18.21 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14",
}
-- 每请求生成身份头。沙箱无 os/math用 meta.timestamp + 请求体哈希派生:
-- 同秒内重复请求 id 相同可接受zen 只校验存在性,不校验格式)。
local function rand_id(prefix, seed)
return prefix .. string.sub(sha256_hex(seed), 1, 24)
end
function adapter.build_headers(meta)
local ts = tostring(meta.timestamp or "")
return {
["User-Agent"] = adapter.headers["User-Agent"],
["x-opencode-client"] = "cli",
-- project 固定:同网关实例共享一个工作区身份
["x-opencode-project"] = string.sub(sha256_hex("llmsproxy|" .. (meta.source and meta.source.name or "")), 1, 32),
["x-opencode-session"] = rand_id("ses_", "session|" .. ts),
["x-opencode-request"] = rand_id("msg_", "request|" .. ts .. "|" .. tostring(meta.body or "")),
}
end
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
-- zen 上游 schema 只接受 text content part无视觉/音频能力):多模态 part
-- image_url / input_audio / file 等)一律剥离;因此失去全部 content 的
@ -32,6 +52,8 @@ function adapter.transform_request(raw_body)
if not ok then return raw_body end
req.disable_thinking = nil
req.extra_body = nil
if type(req.stream_options) ~= "table" then req.stream_options = {} end
req.stream_options.include_usage = true
if req.messages then
local kept = {}
for _, msg in ipairs(req.messages) do
@ -84,8 +106,9 @@ function adapter.transform_response(raw_body)
local ch = resp.choices[1]
if type(ch.message) == "table" then
unified.content = ch.message.content or ""
if ch.message.reasoning_content then
unified.reasoning_content = ch.message.reasoning_content
local reasoning = ch.message.reasoning_content or ch.message.reasoning
if reasoning then
unified.reasoning_content = reasoning
end
if type(ch.message.tool_calls) == "table" then
local tcs = {}
@ -143,8 +166,10 @@ function adapter.transform_stream_chunk(raw_chunk)
if uses ~= nil then
unified.usage = uses
end
if delta.reasoning_content then
unified.reasoning_content = delta.reasoning_content
-- zen 用 reasoning 字段承载推理文本OpenAI 惯例是 reasoning_content
local reasoning = delta.reasoning_content or delta.reasoning
if reasoning then
unified.reasoning_content = reasoning
end
if delta.tool_calls then
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments

View File

@ -115,12 +115,31 @@ func TestOpenCodeAdapterFingerprint(t *testing.T) {
t.Fatal(err)
}
defer vm.Stop()
hdrs, err := vm.BuildHeaders("opencode", map[string]interface{}{"api_key": "public"})
hdrs, err := vm.BuildHeaders("opencode", map[string]interface{}{
"api_key": "public",
"timestamp": 1700000000,
"body": `{"model":"x"}`,
"url": "https://opencode.ai/zen/v1/chat/completions",
"method": "POST",
})
if err != nil {
t.Fatalf("build headers: %v", err)
}
if ua := hdrs["User-Agent"]; ua != "opencode/0.1.0" {
t.Fatalf("opencode adapter must send the opencode client UA, got %q", ua)
// zen 按客户端指纹路由请求池UA 必须是 opencode 真实格式,
// 且必须携带 x-opencode-* 身份头,否则带 tools 的请求会被上游
// 判为匿名客户端并返回 network_error 空流(表现为空回复)。
if ua := hdrs["User-Agent"]; !strings.HasPrefix(ua, "opencode/") ||
!strings.Contains(ua, "ai-sdk/provider-utils/") {
t.Fatalf("opencode adapter must send the real opencode client UA, got %q", ua)
}
for _, h := range []string{"x-opencode-client", "x-opencode-project", "x-opencode-session", "x-opencode-request"} {
if hdrs[h] == "" {
t.Fatalf("opencode adapter must send %q (zen client fingerprint), got %v", h, hdrs)
}
}
hdrs2, _ := vm.BuildHeaders("opencode", map[string]interface{}{"api_key": "public", "timestamp": 1700000001, "body": `{"model":"y"}`, "method": "POST"})
if hdrs["x-opencode-request"] == hdrs2["x-opencode-request"] {
t.Fatalf("x-opencode-request must vary per request, got %q twice", hdrs["x-opencode-request"])
}
if hdrs["Authorization"] != "" {
t.Fatalf("opencode adapter must not hardcode Authorization (config api_key supplies it), got %q", hdrs["Authorization"])