diff --git a/internal/lua/adapters/opencode.lua b/internal/lua/adapters/opencode.lua index ce34a20..aa549a6 100644 --- a/internal/lua/adapters/opencode.lua +++ b/internal/lua/adapters/opencode.lua @@ -4,14 +4,34 @@ adapter.name = "opencode" adapter.version = "1.0.0" adapter.endpoint = "/chat/completions" --- opencode.ai zen 网关按 User-Agent 指纹识别官方客户端并把请求分到免费额度池; --- 非官方 UA(curl/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 diff --git a/internal/lua/vm_test.go b/internal/lua/vm_test.go index 5a9f913..6cd3a14 100644 --- a/internal/lua/vm_test.go +++ b/internal/lua/vm_test.go @@ -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"])