mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 真实缺陷(我自己的会话被它打断过)
生产实测(2026-10-16):
provider localzen: api error 400: Upstream request failed: [invalid_request_error]
The `reasoning_content` in the thinking mode must be passed back to the API.
审计文件里成串 502,紧跟在一个 prompt_tokens=139330 的成功请求之后。
根因:`transform_request` 里写着 `msg.reasoning_content = nil`,注释理由是
"Zen 免费池不接受回传(Go 侧相反)"。**该假设方向是反的** —— 上游要的就是它。
## ★ 为什么这个缺陷极难归因(值得记)
失败是**交替**出现的:只有当上一轮**真的产出了 reasoning**(thinking 模型 +
reasoning_effort 开启 + 长上下文)时,回放那段历史才会被拒;普通回合照常 200。
于是"同一个模型同一会话,成功与失败交替"——看起来像**上游抖动或限流**,
而客户端看到的 502 与"上一轮想过"这件事**毫无关联**。
我一开始也走了弯路:用简短的 curl 复现不出来(8/8 成功),因为那些请求的
历史里没有"模型自己产出的 reasoning"。**必须让上一轮真的产生思考**才复现。
参考实现的方向一致:`openai-compat.ts` 的 `serializeMessages`(opencode 适配器
直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
`...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
即「有就回传」,无额外条件。
## 修法
保留 `reasoning_content`(只把**空串**丢掉 —— 多余字段部分上游会挑)。
不改成"仅当 thinking 开启才回传":我们**无法**从请求可靠判定上游是否处于
thinking 模式(真正知道的是上游)。而"有就回传"是零风险方向 ——
上游不需要时忽略即可(实测传与不传同为 200),需要时缺了就是硬 400。
## ★ 顺带发现的第二处问题
提交 `6113924` 的说明声称
"Test: TestOpenCodeGoVsZenReasoning pins the Go-keeps / Zen-strips split" ——
**该测试在全仓不存在**(`git log -S` 全历史搜索为空)。也就是说这条行为
**从未被任何判据固定**,所以它反了也没人知道。本轮补上真正的判据。
## 判据(2 条 + 变异 1/1)
- `TestOpenCodeZenKeepsAssistantReasoningContent`:assistant 回合带 reasoning 时
出站体必须有它。**咬住"被剥掉"这个具体行为**,而不是"请求成功"——
后者在非 thinking 回合下永远为真,测不出本缺陷。
- `TestOpenCodeZenDropsEmptyReasoningContent`:空串必须丢掉(配对判据,
防"改成无条件保留"这类过度修正)。
变异:恢复 `msg.reasoning_content = nil` → 报红。
## 说明
`opencodezen.lua` 在仓库内,而 `deploy.sh` 的 `sync_adapters` 只覆盖
**与仓库同名**的适配器文件 ⇒ 本修复会自动生效(生产另有 opencodego/opencode/
agentrouter 三个仅存在于线上的文件,不受影响)。
515 lines
26 KiB
Lua
515 lines
26 KiB
Lua
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"。
|
||
-- ★ reasoning_content:**保留**(与 Go 侧一致)。此前这里写「剥掉」是基于
|
||
-- 「免费池以非 thinking 模型为主、回传会报错」的假设,**生产实测已证伪** ——
|
||
-- 剥掉恰恰会引发
|
||
-- The `reasoning_content` in the thinking mode must be passed back to the API
|
||
-- (详见 transform_request 内的长注释:失败只在「上一轮真的产出了 reasoning」
|
||
-- 时才出现,故表现为同一会话里成功/失败交替,极难归因)。
|
||
-- 参考的共享序列化器(openai-compat.ts)也是「有就回传」。
|
||
-- * 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
|
||
-- ★ 必须**保留** reasoning_content(不再剥离)。
|
||
--
|
||
-- 历史:这里原先写 `msg.reasoning_content = nil`,理由是「Zen 免费池
|
||
-- 不接受回传」。**生产实测证伪**(2026-10-16):
|
||
-- provider localzen: api error 400: Upstream request failed:
|
||
-- [invalid_request_error] The `reasoning_content` in the thinking
|
||
-- mode must be passed back to the API.
|
||
-- 失败形态很隐蔽:**同一模型同一会话里成功与失败交替出现** ——
|
||
-- 因为只有当上一轮**真的产出了 reasoning**(thinking 模型、
|
||
-- reasoning_effort 开启、长上下文)时,回放该历史才会被拒;
|
||
-- 剥掉之后,客户端看到的 502 与「上一轮想过」这件事毫无关联。
|
||
-- 审计文件里能看到成串 502 紧跟在一个 prompt_tokens=139330 的
|
||
-- 成功请求之后 —— 那正是「想了、但下一轮我们没把它还回去」。
|
||
--
|
||
-- 参考实现(`openai-compat.ts` 的 serializeMessages,opencode 适配器
|
||
-- 直接复用)在 assistant 回合携带 reasoning 时**就带上**该字段:
|
||
-- `...reasoning.length > 0 ? { reasoning_content: reasoning } : {}`
|
||
-- 即「有就回传」,没有额外条件 —— 与本适配器此前的假设方向相反。
|
||
--
|
||
-- ⚠️ 不改成「仅当 thinking 开启才回传」:我们**无法**从请求里可靠
|
||
-- 判定上游是否处于 thinking 模式(真正知道的是上游)。而「有就回传」
|
||
-- 是零风险方向 —— 上游不需要时忽略即可(实测传与不传同为 200),
|
||
-- 需要时缺了就是硬 400。
|
||
if type(msg.reasoning_content) == "string" and msg.reasoning_content == "" then
|
||
msg.reasoning_content = nil
|
||
end
|
||
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
|