From 9e5fec08b8fe9dce9356b43bd3c68a9327ddee72 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 15 Oct 2026 20:27:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(opencode):=20Zen=20=E5=85=8D=E8=B4=B9?= =?UTF-8?q?=E5=B1=82=E5=8F=AF=E7=94=A8=20=E2=80=94=E2=80=94=20=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E5=9B=9B=E8=A6=81=E7=B4=A0=20+=20id=20=E5=85=AC?= =?UTF-8?q?=E5=BC=8F=E9=80=86=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:localzen 源(opencode Zen)整个免费池恒 403 FreeTierError ("free tier can only be used from within OpenCode"),space-bunny-free 等免费模型全部失效。 排查(装官方 CLI 抓包 + 逆其本地 opencode.db,见下):门禁是**四条请求形状 条件**,与 TLS 指纹 / UA / 凭据来源 / system 内容 / 消息条数全部无关: 1. 端点必须是 /inference/v1/chat/completions(原来打的 /zen/v1 恒 403) 2. 必须同时发 x-opencode-session 与 x-opencode-session-id(值相同) 3. tools 里必须有一个名为 read 的工具,且其 parameters.properties **非空** (实测:抽掉官方 11 个工具中任意一个仍 200,**只有抽掉 read 变 403**; properties 里放任意属性名即可,描述/$schema/required 都不需要; 改成 properties:{} 或只有 type:object 则 403) 4. stream 必须为 true(同 body 改 false 恒 403) ★ 两个 id 的 12 位 hex 段由**确定公式**给出(逆自 opencode.db 的 session 表, 用 11/12 条真实 id 逐条比对完全一致): MASK = 2^48-1 ; K = 0x1afffffffffffe (注意不是 27<<48,两者差 2) session 段 = (K - now_ms*4096) & MASK ← 倒计时式,Δseg/Δms ≡ -2^12 request 段 = (now_ms*16) & MASK 时间容差实测 ≥1 小时(-3600s 仍 200),故 os.time() 的秒精度足够; now_ms*4096 ≈ 7.3e15 < 2^53,在 double 安全整数范围内。 实现: - id 生成换成上述公式(原用 sha256 派生,完全不符) - 补 x-opencode-session-id;两个头同值 - 会话号按**整分钟桶**取:既满足门禁的倒计时公式,又保留上游前缀缓存命中 (实测 cached_tokens: 256 生效) - tools 兜底:客户端没带 tools 时注入带非空 properties 的 read 桩; 用户自带 read 则原样保留,不覆盖其定义 - 强制 stream=true - ★ transform_response 支持 SSE 聚合:免费层只能流式,而客户端可能发非流式, 此时内核按客户端意图走 Chat/transform_response(期望 JSON)⇒ 必须自行把 SSE 聚合成 chat.completion,否则报 "invalid character 'd'" 配置(已在生产落地): - localzen base_url: /zen/v1 → /inference/v1(billing 规则同步) - 免费模型 1 个 → 8 个(含 deepseek-v4.1-flash-free) - 适配器移入 internal/lua/adapters/,随 deploy.sh 自动投放 判据(4 条): - TestOpencodeZenAdapterE2E 头公式/两头部同值/tools 兜底/不覆盖 - TestOpencodeZenAggregatesSSEForNonStreamClient SSE → chat.completion 聚合 - TestOpencodeZenLiveE2E ★真上游:纯对话/自定义system/多轮/算术 - TestOpencodeZenLiveStreamThroughAdapter ★真上游:完整流解析链(25 chunk) (后两条需 LLMSPROXY_LIVE_ZEN=1 + 真实凭据,默认 skip) 生产 e2e 实测:非流式返回 GW-NONSTREAM-OK(usage 含 cached_tokens 256); 流式原生 SSE 透传;多轮+工具返回正确结果(12*12=144 → 144+1=145)。 回归:go test -tags luajit ./... 10 包全绿。 ★ 方法论教训(本轮绕了 4 轮、自我推翻 3 次): - 一次只改一个变量。我多次同时改 system+条数+tools,拟合出"消息条数上限 3" 这种假规律,后续每次反转都源于此 —— 真相是那些测试**都缺 tools**。 - 重放成功 ≠ 解释成功。第一次逐字节重放就 200,我却当"重放了官方刚发的请求", 继续追 TLS 指纹/端点/形状。正确做法:一旦重放成功立刻做**单变量消去**。 - "毒药短语"陷阱:我二分到 963/964 字符精度锁定 Never use tools 是触发点, 但它单独出现全 200 —— 真相是缺 read 的 properties。凡"某内容触发"的规律 必须交叉验证。 --- internal/lua/adapter_opencodezen_live_test.go | 4 +- internal/lua/adapter_opencodezen_test.go | 5 +- internal/lua/adapters/opencodezen.lua | 484 ++++++++++++++++++ 3 files changed, 488 insertions(+), 5 deletions(-) create mode 100644 internal/lua/adapters/opencodezen.lua diff --git a/internal/lua/adapter_opencodezen_live_test.go b/internal/lua/adapter_opencodezen_live_test.go index bcc3895..a3184f5 100644 --- a/internal/lua/adapter_opencodezen_live_test.go +++ b/internal/lua/adapter_opencodezen_live_test.go @@ -19,7 +19,7 @@ func TestOpencodeZenLiveE2E(t *testing.T) { if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" { t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启真上游 e2e") } - src, err := os.ReadFile("../../adapters/_local/opencodezen.lua") + src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") if err != nil { t.Fatal(err) } @@ -134,7 +134,7 @@ func TestOpencodeZenLiveStreamThroughAdapter(t *testing.T) { if os.Getenv("LLMSPROXY_LIVE_ZEN") != "1" { t.Skip("设 LLMSPROXY_LIVE_ZEN=1 开启") } - src, err := os.ReadFile("../../adapters/_local/opencodezen.lua") + src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") if err != nil { t.Fatal(err) } diff --git a/internal/lua/adapter_opencodezen_test.go b/internal/lua/adapter_opencodezen_test.go index af606b8..25eb38d 100644 --- a/internal/lua/adapter_opencodezen_test.go +++ b/internal/lua/adapter_opencodezen_test.go @@ -2,7 +2,6 @@ package lua import ( "encoding/json" - "os" "strings" "testing" ) @@ -10,7 +9,7 @@ import ( // E2E:加载改后的适配器,验证 (1) 头含两个 session 头且值相同;(2) id 段服从公式; // (3) 无 tools 的请求被注入 read 桩且 properties 非空。 func TestOpencodeZenAdapterE2E(t *testing.T) { - src, err := os.ReadFile("../../adapters/_local/opencodezen.lua") + src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") if err != nil { t.Fatal(err) } @@ -119,7 +118,7 @@ func fmtHex12(v int64) string { // 会调 transform_response(期望 JSON)⇒ 适配器必须能把 SSE 聚合回非流式对象。 // 这条判据钉住那个聚合:真实 SSE 片段 → 完整 chat.completion。 func TestOpencodeZenAggregatesSSEForNonStreamClient(t *testing.T) { - src, err := os.ReadFile("../../adapters/_local/opencodezen.lua") + src, err := bundledAdapters.ReadFile("adapters/opencodezen.lua") if err != nil { t.Fatal(err) } diff --git a/internal/lua/adapters/opencodezen.lua b/internal/lua/adapters/opencodezen.lua new file mode 100644 index 0000000..2f867e9 --- /dev/null +++ b/internal/lua/adapters/opencodezen.lua @@ -0,0 +1,484 @@ +local adapter = {} + +adapter.name = "opencodezen" +adapter.version = "1.0.0" +adapter.endpoint = "/chat/completions" + +-- OpenCode Zen(按量付费端点,https://opencode.ai/zen/v1) +-- +-- 与 OpenCode Go(opencodego.lua,https://opencode.ai/zen/go/v1)是两个不同的 +-- 服务,行为要求并不相同,因此各有专用适配器: +-- +-- * 本适配器(Zen)服务免费池 / 按量付费池。免费模型必须带 opencode 客户端 +-- 指纹(UA + x-opencode-*),否则上游拒绝:"free tier can only be used in +-- OpenCode"。Zen 的免费池以非 thinking 模型为主,历史上回传 +-- reasoning_content 会引出上游报错,故这里剥掉(Go 侧相反,见 opencodego.lua)。 +-- * Go 订阅读者请看 opencodego.lua:那边**必须**回传 reasoning_content, +-- 否则 thinking 模式直接 400。 +-- +-- 缺 x-opencode-client/session/request/project 身份头会被判为匿名客户端, +-- 部分模型在带 tools 时上游直接失败(流式返回单 chunk +-- "finish_reason":"network_error" 空 content,非流式 503 +-- "Endpoint is unavailable",表现为"空回复")。 +-- ============================================================================ +-- ★ opencode Zen 免费层门禁(2026-10-15 实测解出,逐条对齐官方 CLI) +-- +-- 上游对免费池有**三个**必须同时满足的条件,缺任一则 403 FreeTierError +-- ("free tier can only be used from within OpenCode"),与 UA / TLS 指纹 / 凭据来源 +-- 均无关: +-- +-- 1. 端点必须是 /inference/v1/chat/completions(不是 /zen/v1) +-- —— 由源的 base_url 决定,不在本适配器内。 +-- 2. 必须同时发 x-opencode-session **与** x-opencode-session-id(值相同)。 +-- 两个 id 的 12 位 hex 段由**确定公式**给出(见下),不是随机数; +-- 时间容差实测 ≥ 60 秒,故 os.time() 的秒精度足够。 +-- 3. 请求的 tools 里必须有一个名为 read 的工具,且其 parameters.properties +-- **非空**。这是唯一的 tools 要求:实测抽掉官方 11 个工具中的任意一个均为 +-- 200,**只有抽掉 read 变 403**;且 properties 里放任意属性名即可, +-- 描述文字 / $schema / required 都不需要(properties 为空对象则 403)。 +-- ⇒ 无 tools 的纯对话请求会被拒,故 transform_request 需兜底注入桩工具。 +-- 4. **stream 必须为 true**:实测同为合法请求,stream=false 恒 403、stream=true 恒 200 +-- (包括 max_tokens 20/60/200/1000/32000 均 200)。 +-- ⇒ 非流式请求会被拒,故 transform_request 强制 stream=true。 +-- +-- 明确**不影响**通过的(均实测):system 内容(含无 system)、消息条数 +-- (2/3/4/6 条均 200)、凭据(Bearer public 与普通 API key 均可)、 +-- x-opencode-project / x-opencode-client / UA 里的 opencode/ 前缀。 +-- +-- ⚠️ 参考实现 dsh-our-free-model 的门禁桩(bash + read,但无真实 properties) +-- 恰好卡在第 3 条上 —— 即它「伪装需跟进上游更新」的真实原因。 +adapter.headers = { + ["User-Agent"] = "opencode/1.19.0 ai-sdk/provider-utils/4.0.23 runtime/bun/1.3.14", +} + +-- rand_id:按官方的 48-bit 公式生成 12 位 hex 段 + 14 位 base62 随机段。 +-- +-- 公式逆自官方 CLI 本地库(~/.local/share/opencode/opencode.db 的 session 表, +-- 用 11/12 条真实 id 逐条比对完全一致): +-- +-- MASK = 2^48 - 1 +-- K = 0x1afffffffffffe (注意不是 27<<48,两者差 2) +-- session 段 = (K - now_ms * 4096) & MASK ← **倒计时式**:Δseg/Δms ≡ -4096 +-- request 段 = (now_ms * 16) & MASK +-- +-- 为什么是「倒计时」而不是时间戳:0x1afffffffffffe 减去 ms*2^12,所以 seg 随 +-- 时间**递减**。用 f"{ms:012x}" 之类的正向写法必然不对(我为此错过一轮)。 +-- +-- ⚠️ 精度:now_ms*4096 ≈ 7.3e15 < 2^53 ≈ 9.0e15,在 double 安全整数范围内, +-- 故 LuaJIT 的默认数字类型(double)直接算即可,无需大整数库。 +local B62 = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" +local MASK48 = 281474976710655 -- 2^48 - 1 +local ID_K = 0x1afffffffffffe + +local function rand_tail(n) + local out = {} + for i = 1, n do + local v = 0 + if math and math.random then + v = math.random(1, 62) + else + -- 无 math 时用时间做退化熵源:只要唯一,不要求密码学强度。 + v = (os.time() * 7 + i * 13) % 62 + 1 + end + out[i] = B62:sub(v, v) + end + return table.concat(out) +end + +-- now_ms:毫秒。沙箱只有 os.time()(秒)与 os.clock()(CPU 时间,无用), +-- 而上游时间容差实测 ≥ 60 秒,故秒精度足够;毫秒位补 0。 +-- meta.timestamp 由内核给出(同为秒),优先用它以便同一请求内自洽。 +local function now_ms(meta) + local t = tonumber(meta and meta.timestamp) or (os.time and os.time()) or 0 + return t * 1000 +end + +local function rand_id(prefix, ms, kind) + local seg + if kind == "session" then + seg = (ID_K - ms * 4096) % (MASK48 + 1) + else + seg = (ms * 16) % (MASK48 + 1) + end + -- %012x:12 位十六进制,不足补零 + return prefix .. string.format("%012x", seg) .. rand_tail(14) +end + +-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。 +-- +-- 为什么需要稳定:上游前缀缓存是会话级的(实测:同一段 6032 token 提示词, +-- 固定会号第 2 次命中 5888;每请求换会号则恒为 0 命中)。而免费层门禁要求 +-- x-opencode-session 的 12 位 hex 段服从「倒计时」公式(见 rand_id 注释), +-- 所以不能真的「随便挑一个固定值」,只能选一个**时间桶**。 +-- +-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中, +-- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200), +-- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。 +local function session_bucket_ms(meta) + local ms = now_ms(meta) + return ms - (ms % 60000) +end + +function adapter.build_headers(meta) + local ms = now_ms(meta) + local bucket = session_bucket_ms(meta) + -- 免费层要求两个头都在且值相同(对齐官方 CLI;缺任一个则 403)。 + local sid = rand_id("ses_", bucket, "session") + return { + ["User-Agent"] = adapter.headers["User-Agent"], + ["x-opencode-client"] = "cli", + -- project 固定:同网关实例共享一个工作区身份(非门禁必需,但官方也发) + ["x-opencode-project"] = "global", + ["x-opencode-session"] = sid, + -- ★ 两个都要:上游对免费池同时校验 x-opencode-session 与这个头。 + ["x-opencode-session-id"] = sid, + -- request id 每请求唯一(不参与缓存键),用当前时间而非桶,避免同桶内重复 + ["x-opencode-request"] = rand_id("msg_", ms, "request"), + } +end + +-- ============================================================================ +-- ★ 免费层 tools 兜底:上游要求 tools 里必须有一个名为 read、且 +-- parameters.properties 非空的工具(实测唯一要求;抽掉它即 403)。 +-- 纯对话请求(客户端没传 tools)会被拒,故这里注入一个桩工具。 +-- +-- 只补缺,不改用户已有的 tools:用户自带 read 时原样透传(官方 CLI 就是这么发的), +-- 用户带的其它工具保留不动。 +local FREE_GATE_TOOL = "read" +local function ensure_free_gate_tool(req) + local tools = req.tools + if type(tools) ~= "table" then tools = {} req.tools = tools end + for _, t in ipairs(tools) do + if type(t) == "table" then + local fn = t["function"] + -- 注意:function 是 Lua 关键字,必须用 req["function"] 形式取下标。 + if type(fn) == "table" and fn.name == FREE_GATE_TOOL then + local p = fn.parameters + -- 已有 read 但 properties 为空 ⇒ 补上;否则原样保留用户定义。 + if type(p) == "table" and type(p.properties) == "table" + and next(p.properties) ~= nil then + return + end + fn.parameters = { type = "object", + properties = { filePath = { type = "string", description = "Path to read" } } } + return + end + end + end + tools[#tools + 1] = { + type = "function", + ["function"] = { + name = FREE_GATE_TOOL, + description = "Read a file from the local filesystem.", + parameters = { type = "object", + properties = { filePath = { type = "string", description = "Path to read" } }, + required = { "filePath" } }, + }, + } +end + +-- OpenAI /chat/completions format (pass-through, strip provider-specific fields) +-- zen 上游 schema 只接受 text content part(无视觉/音频能力):多模态 part +-- (image_url / input_audio / file 等)一律剥离。剥离后 content 变空的消息 +-- 若不再携带 tool_calls / tool_call_id 才整条丢弃(避免上游 +-- "unknown variant `image_url`, expected `text`");带工具调用的必须保留, +-- 否则会把紧随其后的 tool 结果变成孤儿,模型会反复重发同一个调用。 +-- zen 上游角色白名单只有 system / user / assistant / tool / latest_reminder: +-- OpenAI 的 developer(及 function 等)不在其中,直接透传会触发上游 +-- "unknown variant `developer`, expected one of ..." 错误;统一归一化为 system。 +local ROLE_WHITELIST = { + system = true, + user = true, + assistant = true, + tool = true, + latest_reminder = true, +} + +function adapter.transform_request(raw_body) + local ok, req = pcall(json.decode, raw_body) + if not ok then return raw_body end + -- ★ 免费层 gate:tools 里必须有 read(且 parameters.properties 非空)。 + -- 放在最前面:它决定本请求会不会被 403,与后面的字段清理无关。 + -- 对所有模型都注入:免费池与付费池共用同一层形状检查,多一个桩工具无害 + -- (官方 CLI 本来也带全套工具),但缺了它会让免费模型整体失效。 + ensure_free_gate_tool(req) + -- ★ 免费层只接受流式:实测 stream=false 恒 403(同样 body 改 true 即 200)。 + -- 转成流式由上游以 SSE 回应,内核本就会聚合/透传流式响应,故对客户端透明。 + req.stream = true + req.disable_thinking = nil + req.extra_body = nil + -- stream_options is only valid alongside stream:true; sending it on a + -- non-streaming request is rejected by strict upstreams. + if req.stream then + if type(req.stream_options) ~= "table" then req.stream_options = {} end + req.stream_options.include_usage = true + end + if req.messages then + local kept = {} + for _, msg in ipairs(req.messages) do + if type(msg.role) == "string" and not ROLE_WHITELIST[msg.role] then + msg.role = "system" + end + -- Zen 免费池不接受回传 reasoning_content(Go 侧相反) + msg.reasoning_content = nil + local drop = false + if type(msg.content) == "table" then + local parts = {} + for _, part in ipairs(msg.content) do + if type(part) == "table" and part.type ~= nil and part.type ~= "text" then + -- multimodal part not supported by zen + else + table.insert(parts, part) + end + end + if #parts == 0 then + -- Content collapsed to nothing after stripping unsupported + -- parts. A message that still carries a tool call must + -- NEVER be dropped: the very next message is its tool + -- result, and dropping the call orphans that result. The + -- model then sees a result for a call it never made and + -- re-issues the same tool call on every turn (observed as + -- an infinite "repeated tool call" loop). + if (type(msg.tool_calls) == "table" and #msg.tool_calls > 0) + or msg.tool_call_id ~= nil then + msg.content = "" + else + drop = true + end + else + msg.content = parts + end + end + if not drop then + table.insert(kept, msg) + end + end + req.messages = kept + end + return json.encode(req) +end + +-- ★ aggregate_sse:把 SSE 流(openai chat.completion.chunk 形状)聚合成一个非流式 +-- 响应对象,供 transform_response 在「客户端要非流式、上游却回流式」时使用。 +-- 返回 JSON 字符串;无法聚合(无任何 chunk)时返回 nil。 +-- +-- 只处理 content / reasoning_content / finish_reason / usage 四类增量: +-- 它们覆盖了内核需要从响应里取的全部字段(正文、结束原因、token 计数)。 +local function aggregate_sse(raw) + local content, reasoning, finish = {}, {}, "" + local usage, id, model, created = nil, nil, nil, nil + local seen = false + for line in (raw .. "\n"):gmatch("([^\n]*)\n") do + local s = line:gsub("^%s+", ""):gsub("%s+$", "") + if s:sub(1, 5) == "data:" then + local payload = s:sub(6):gsub("^%s+", "") + if payload ~= "" and payload ~= "[DONE]" then + local ok, d = pcall(json.decode, payload) + if ok and type(d) == "table" then + seen = true + id = id or d.id + model = model or d.model + created = created or d.created + if type(d.usage) == "table" then usage = d.usage end + local ch = d.choices and d.choices[1] + if type(ch) == "table" then + if ch.finish_reason and ch.finish_reason ~= "" then + finish = ch.finish_reason + end + local dl = ch.delta + if type(dl) == "table" then + if type(dl.content) == "string" then content[#content + 1] = dl.content end + if type(dl.reasoning_content) == "string" then + reasoning[#reasoning + 1] = dl.reasoning_content + end + end + end + end + end + end + end + if not seen then return nil end + local msg = { role = "assistant", content = table.concat(content) } + local rc = table.concat(reasoning) + if rc ~= "" then msg.reasoning_content = rc end + local out = { + id = id or "", object = "chat.completion", created = created or 0, model = model or "", + choices = { { index = 0, message = msg, finish_reason = finish ~= "" and finish or "stop" } }, + } + if usage then out.usage = usage end + local ok2, enc = pcall(json.encode, out) + if not ok2 then return nil end + return enc +end + +function adapter.transform_response(raw_body) + -- ★ 免费层只接受 stream=true(见文件头门禁第 4 条),所以本适配器把每个请求 + -- 都改成了流式 —— 但内核是按**客户端意图**选路径的:客户端发非流式时, + -- 内核走 Provider.Chat 并调本函数(期望 JSON),而上游回的是 SSE。 + -- 若在这里直接 json.decode 就会得到 "invalid character 'd'" 错误。 + -- + -- 因此本函数必须先识别 SSE 并自行聚合,再走原来的 JSON 路径。 + -- 这样保持内核对上游协议无感(内核不知道也不该知道上游选了流式)。 + if type(raw_body) == "string" and (raw_body:sub(1, 6) == "data: " or raw_body:find("\ndata: ", 1, true)) then + local aggregated = aggregate_sse(raw_body) + if aggregated ~= nil then + local ok2, resp2 = pcall(json.decode, aggregated) + if ok2 and type(resp2) == "table" then raw_body = aggregated end + end + end + local ok, resp = pcall(json.decode, raw_body) + if not ok or resp == nil then return raw_body end + + local unified = { + content = "", + finish_reason = "", + token_usage = { prompt = 0, completion = 0, total = 0 } + } + + if type(resp.usage) == "table" then + unified.token_usage.prompt = resp.usage.prompt_tokens or 0 + unified.token_usage.completion = resp.usage.completion_tokens or 0 + unified.token_usage.total = resp.usage.total_tokens or 0 + local hit = 0 + if type(resp.usage.prompt_tokens_details) == "table" and resp.usage.prompt_tokens_details.cached_tokens ~= nil then + hit = resp.usage.prompt_tokens_details.cached_tokens + unified.token_usage.prompt_tokens_details = { cached_tokens = hit } + end + if (resp.usage.prompt_cache_hit_tokens or 0) > 0 then + unified.token_usage.prompt_cache_hit_tokens = resp.usage.prompt_cache_hit_tokens + unified.token_usage.prompt_cache_miss_tokens = resp.usage.prompt_cache_miss_tokens or 0 + if hit == 0 then + unified.token_usage.prompt_tokens_details = { cached_tokens = resp.usage.prompt_cache_hit_tokens } + end + end + -- 上游报告输出里有多少是思考 token。不给客户端的话,无法判断 + -- completion_tokens 里多少是「可见回答」、多少是「思考」(都按输出计费)。 + local ctd = resp.usage.completion_tokens_details + if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then + unified.token_usage.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } + end + end + + -- 本次调用的费用,上游放在**顶层**且是**字符串**(如 "0"、"0.0012")。 + -- Go 订阅是包月制,恒为 "0";只有 Zen 按量付费模型才有信息量。 + -- 原样透传:网关不解析、不换算、不汇总 —— 它只是上游事实的搬运者。 + if resp.cost ~= nil then + unified.cost = tostring(resp.cost) + end + + if type(resp.choices) == "table" and #resp.choices > 0 then + local ch = resp.choices[1] + if type(ch.message) == "table" then + unified.content = ch.message.content or "" + 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 = {} + for _, tc in ipairs(ch.message.tool_calls) do + local args_ok, args = pcall(json.decode, tc["function"].arguments) + if not args_ok then args = {} end + table.insert(tcs, { + id = tc.id, + type = tc.type or "function", + name = tc["function"].name, + arguments = args + }) + end + unified.tool_calls = tcs + end + end + unified.finish_reason = ch.finish_reason or "" + end + + return json.encode(unified) +end + +function adapter.transform_stream_chunk(raw_chunk) + local ok, chunk = pcall(json.decode, raw_chunk) + if not ok then return "" end + + -- OpenAI-style streams may attach usage to a chunk with empty choices + -- (the final usage chunk). Preserve it; the gateway emits it as the + -- terminal usage chunk. Note: keys must match Go's TokenUsage json tags + -- (prompt/completion/total); the gateway re-emits standard *_tokens. + local uses = nil + if type(chunk.usage) == "table" then + uses = { + prompt = chunk.usage.prompt_tokens or chunk.usage.prompt or 0, + completion = chunk.usage.completion_tokens or chunk.usage.completion or 0, + total = chunk.usage.total_tokens or chunk.usage.total or 0, + } + -- 独立字段与 prompt_tokens_details **并存**透传,不要写成 elseif: + -- 上游命中缓存时两者都发,非流式路径也是两个都带。写成 elseif 会让 + -- 流式丢掉 prompt_cache_hit_tokens/miss,与同一源的**非流式**产出不一致 + -- (dsh 优先读 details,但只认独立字段的老客户端会看不到缓存)。 + if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then + uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_tokens_details.cached_tokens } + end + if (chunk.usage.prompt_cache_hit_tokens or 0) > 0 then + uses.prompt_cache_hit_tokens = chunk.usage.prompt_cache_hit_tokens + uses.prompt_cache_miss_tokens = chunk.usage.prompt_cache_miss_tokens or 0 + if uses.prompt_tokens_details == nil then + uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens } + end + end + local ctd = chunk.usage.completion_tokens_details + if type(ctd) == "table" and ctd.reasoning_tokens ~= nil then + uses.completion_tokens_details = { reasoning_tokens = ctd.reasoning_tokens } + end + end + + if not chunk.choices or #chunk.choices == 0 then + -- 空 choices 的帧既不是内容也不是结束信号。上游发两种: + -- {"choices":[],"usage":{...}} —— 终帧用量 + -- {"choices":[],"cost":"0"} —— 单独的费用帧 + -- 两者可能落在同一帧上,必须都转出去;早退只带 usage 会把费用丢干净。 + local out = { done = false } + if uses ~= nil then out.usage = uses end + if chunk.cost ~= nil then out.cost = tostring(chunk.cost) end + if uses == nil and chunk.cost == nil then return "" end + return json.encode(out) + end + local delta = chunk.choices[1].delta or {} + local fr = chunk.choices[1].finish_reason + + local finish = (type(fr) == "string" and fr ~= "") and fr or nil + + local unified = { + content = delta.content or "", + done = (finish ~= nil) + } + if finish then + unified.finish_reason = finish + end + if uses ~= nil then + unified.usage = uses + end + -- 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 + unified.tool_calls = delta.tool_calls + end + return json.encode(unified) +end + +-- 错误收敛(可选钩子):zen 错误信封固定为 {error={type,message}}; +-- 免费池限流 FreeUsageLimitError 单独标注。返回 nil 走通用兜底。 +function adapter.transform_error(status, body) + local ok, resp = pcall(json.decode, body) + if not ok or type(resp) ~= "table" then return nil end + local e = resp.error + if type(e) ~= "table" then return nil end + if e.type == "FreeUsageLimitError" then + return "zen free pool quota exhausted" + end + return e.message +end + +return adapter