mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-09-20 17:07:59 +00:00
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:
@ -4,14 +4,34 @@ adapter.name = "opencode"
|
|||||||
adapter.version = "1.0.0"
|
adapter.version = "1.0.0"
|
||||||
adapter.endpoint = "/chat/completions"
|
adapter.endpoint = "/chat/completions"
|
||||||
|
|
||||||
-- opencode.ai zen 网关按 User-Agent 指纹识别官方客户端并把请求分到免费额度池;
|
-- opencode.ai zen 网关按客户端指纹(UA + x-opencode-* 头)路由请求池:
|
||||||
-- 非官方 UA(curl/Go 默认等)会被分到匿名池并触发 FreeUsageLimitError。
|
-- 缺少 x-opencode-client/session/request/project 身份头的请求会被判为匿名
|
||||||
-- 因此固定发送 opencode 客户端的 UA;配合源配置 api_key: "public"(官方无 key
|
-- 客户端,x-preview-f-free 等模型在带 tools 时上游直接失败——流式返回单
|
||||||
-- 客户端实际发送 Bearer public)即可走免费池。
|
-- chunk "finish_reason":"network_error" 空 content,非流式返回 503
|
||||||
|
-- "Endpoint is unavailable",表现为"空回复"。因此除 UA 外必须附带
|
||||||
|
-- x-opencode-* 身份头;session/request 每请求派生唯一值(见 build_headers)。
|
||||||
adapter.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)
|
-- OpenAI /chat/completions format (pass-through, strip provider-specific fields)
|
||||||
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part
|
||||||
-- (image_url / input_audio / file 等)一律剥离;因此失去全部 content 的
|
-- (image_url / input_audio / file 等)一律剥离;因此失去全部 content 的
|
||||||
@ -32,6 +52,8 @@ function adapter.transform_request(raw_body)
|
|||||||
if not ok then return raw_body end
|
if not ok then return raw_body end
|
||||||
req.disable_thinking = nil
|
req.disable_thinking = nil
|
||||||
req.extra_body = 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
|
if req.messages then
|
||||||
local kept = {}
|
local kept = {}
|
||||||
for _, msg in ipairs(req.messages) do
|
for _, msg in ipairs(req.messages) do
|
||||||
@ -84,8 +106,9 @@ function adapter.transform_response(raw_body)
|
|||||||
local ch = resp.choices[1]
|
local ch = resp.choices[1]
|
||||||
if type(ch.message) == "table" then
|
if type(ch.message) == "table" then
|
||||||
unified.content = ch.message.content or ""
|
unified.content = ch.message.content or ""
|
||||||
if ch.message.reasoning_content then
|
local reasoning = ch.message.reasoning_content or ch.message.reasoning
|
||||||
unified.reasoning_content = ch.message.reasoning_content
|
if reasoning then
|
||||||
|
unified.reasoning_content = reasoning
|
||||||
end
|
end
|
||||||
if type(ch.message.tool_calls) == "table" then
|
if type(ch.message.tool_calls) == "table" then
|
||||||
local tcs = {}
|
local tcs = {}
|
||||||
@ -143,8 +166,10 @@ function adapter.transform_stream_chunk(raw_chunk)
|
|||||||
if uses ~= nil then
|
if uses ~= nil then
|
||||||
unified.usage = uses
|
unified.usage = uses
|
||||||
end
|
end
|
||||||
if delta.reasoning_content then
|
-- zen 用 reasoning 字段承载推理文本(OpenAI 惯例是 reasoning_content)
|
||||||
unified.reasoning_content = delta.reasoning_content
|
local reasoning = delta.reasoning_content or delta.reasoning
|
||||||
|
if reasoning then
|
||||||
|
unified.reasoning_content = reasoning
|
||||||
end
|
end
|
||||||
if delta.tool_calls then
|
if delta.tool_calls then
|
||||||
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
|
-- pass raw streaming fragments through; OpenAI clients accumulate index+id+name+arguments
|
||||||
|
|||||||
@ -115,12 +115,31 @@ func TestOpenCodeAdapterFingerprint(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
defer vm.Stop()
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("build headers: %v", err)
|
t.Fatalf("build headers: %v", err)
|
||||||
}
|
}
|
||||||
if ua := hdrs["User-Agent"]; ua != "opencode/0.1.0" {
|
// zen 按客户端指纹路由请求池:UA 必须是 opencode 真实格式,
|
||||||
t.Fatalf("opencode adapter must send the opencode client UA, got %q", ua)
|
// 且必须携带 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"] != "" {
|
if hdrs["Authorization"] != "" {
|
||||||
t.Fatalf("opencode adapter must not hardcode Authorization (config api_key supplies it), got %q", hdrs["Authorization"])
|
t.Fatalf("opencode adapter must not hardcode Authorization (config api_key supplies it), got %q", hdrs["Authorization"])
|
||||||
|
|||||||
Reference in New Issue
Block a user