mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
生产实测逐渠道排查(2026-10-19):
* qoder:PKCE 设备码流无 register 第二跳,落库凭据是裸 token 字符串;
qoderClaim 硬要求结构化 table 导致「缺少凭据对象」(而 account_test
走 token 路径正常 —— 自相矛盾状态)。改为非空裸 tok 就地包
{access_token=tok};补 TestQoderClaimAcceptsBareTokenCredential。
* trae(SOLO 自装适配器):上游对非流式请求也回 SSE(event: 前缀帧),
transform_response 不识别 → 整个 body 原样透传 → Go unmarshal 报
invalid character 'e'。补 aggregate_solo_sse 聚合。
* buddy(CodeBuddy):上游 400 11101 只支持流式。transform_request 强制
stream=true;transform_response 补 SSE 聚合(tool_calls 按 index 拼分片)。
* provider 流式:真实 tool-call 片段之后出现 finish_reason=stop 时归一为
tool_calls(trae 实测如此发);buddy/trae 不再序列化空 tool_calls 表。
* opencode:远端目录端点改为 /zen/v1/models(裸 /v1/models 回 HTML);
兜底目录同步 2026-10-19 实测(space-bunny-free 410 下架等 5 项变化)。
opencodezen 适配器 v1.1.0:会话亲和(client_session / 首 user 消息指纹
分桶),保留免费层门禁四要素。
* 内核:适配器可声明 adapter.hidden = true 退出用户可见列表;被源引用的
隐藏适配器保持可见(源编辑器要回显当前值)。opencodezen 默认隐藏。
* channels UI:切渠道立即清空账号/模型区 + 响应代际校验,修复 qoder 账号
闪现在 opencode 卡片下的渲染竞态。
* channels request_end hook:挂接丢失的 isModelGone/recordDeadModel ——
真实 404(如 codearts GLM-5.3 "The model is not registered")现在会进
deadModels,不再每轮 404 仍可选中。
565 lines
28 KiB
Lua
565 lines
28 KiB
Lua
local adapter = {}
|
||
|
||
adapter.name = "opencodezen"
|
||
adapter.version = "1.1.0"
|
||
adapter.endpoint = "/chat/completions"
|
||
-- ★ hidden:不进用户可见适配器列表(v1.1.0)。本适配器是 OpenCode 免费层的
|
||
-- **门禁专用形态**(强制 stream、注入 read 桩工具、会话号服从倒计时公式),
|
||
-- 直接暴露会被误当成通用 OpenAI 适配器选去接别的上游,结果不可预测。
|
||
-- 被 localzen 源引用时内核仍会把它列出来(源编辑器回显),其余场合隐藏。
|
||
adapter.hidden = true
|
||
|
||
-- 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, affinity)
|
||
local seg
|
||
if kind == "session" then
|
||
seg = (ID_K - ms * 4096) % (MASK48 + 1)
|
||
else
|
||
seg = (ms * 16) % (MASK48 + 1)
|
||
end
|
||
-- %012x:12 位十六进制,不足补零
|
||
-- affinity(可空):会话亲和键。非空时其 8 位 hex 摘要混入 tail ——
|
||
-- 同一分钟桶内不同会话得到不同会号;hex 段不受影响(门禁只校验它)。
|
||
local tail
|
||
if type(affinity) == "string" and affinity ~= "" then
|
||
tail = string.sub(sha256_hex(affinity), 1, 8) .. rand_tail(6)
|
||
else
|
||
tail = rand_tail(14)
|
||
end
|
||
return prefix .. string.format("%012x", seg) .. tail
|
||
end
|
||
|
||
-- session_bucket_ms 决定上游会话号的**稳定周期**(按整分钟对齐)。
|
||
--
|
||
-- 为什么需要稳定:上游前缀缓存是会话级的(实测:同一段 6032 token 提示词,
|
||
-- 固定会号第 2 次命中 5888;每请求换会号则恒为 0 命中)。而免费层门禁要求
|
||
-- x-opencode-session 的 12 位 hex 段服从「倒计时」公式(见 rand_id 注释),
|
||
-- 所以不能真的「随便挑一个固定值」,只能选一个**时间桶**。
|
||
--
|
||
-- 取整分钟(而非秒):同一分钟内的所有请求共享一个会话号 ⇒ 既保留缓存命中,
|
||
-- 又不超出上游的时间容差。实测容差 ≥ 1 小时(-3600s 仍 200),
|
||
-- 所以一分钟桶有很大余量,即便上游收紧到几分钟也仍然安全。
|
||
-- ★ 会话亲和(v1.1.0,移植自 _local_archive 版本):整分钟桶让**所有请求**
|
||
-- 共享会号,多会话并发时互相污染缓存键。改进:桶内再按**会话身份**分桶——
|
||
-- 1) meta.client_session(pi 等真会话客户端自带,会话恒定)
|
||
-- 2) 退路:历史里第一条 user 消息做会话指纹(通用 OpenAI 客户端不发任何
|
||
-- 会话标识,实测抓包确认;第一条 user 消息在整个会话里恒定)
|
||
-- 3) 再退:无 user 消息时退回纯时间桶(与 v1.0 行为一致)
|
||
-- 实现:把会话身份 hash 进 14 位 base62 随机段(hex 段仍服从门禁公式)。
|
||
local function conversation_fingerprint(body)
|
||
if type(body) ~= "string" or body == "" then return "" end
|
||
local ok, req = pcall(json.decode, body)
|
||
if not ok or type(req) ~= "table" then return "" end
|
||
for _, m in ipairs(req.messages or {}) do
|
||
if type(m) == "table" and m.role == "user" then
|
||
local c = m.content
|
||
if type(c) == "string" then
|
||
if c ~= "" then return c end
|
||
elseif type(c) == "table" then
|
||
for _, part in ipairs(c) do
|
||
if type(part) == "table" and part.type == "text" and part.text then
|
||
return part.text
|
||
end
|
||
end
|
||
end
|
||
return ""
|
||
end
|
||
end
|
||
return ""
|
||
end
|
||
|
||
local function session_affinity_key(meta)
|
||
local cs = meta and meta.client_session
|
||
if type(cs) == "string" and cs ~= "" then return cs end
|
||
return conversation_fingerprint(meta and meta.body)
|
||
end
|
||
|
||
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)。
|
||
-- 会话亲和键(可能为空)混入 tail:同一分钟桶内不同会话得到不同会号,
|
||
-- 同一会话跨分钟仍共享指纹 ⇒ 前缀缓存按会话命中而非按分钟命中。
|
||
local sid = rand_id("ses_", bucket, "session", session_affinity_key(meta))
|
||
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
|