mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
G-Star 合规要求下,把"模拟官方客户端、复刻私有协议、自动领取平台额度"这一类 能力整体移出仓库。这些实现依赖各平台未公开的端点、签名算法与官方 client id, 属于绕过客户端校验获取额度,风险高于普通协议适配。 移除(93 个文件): - 适配器:opencodezen(伪装 UA 取免费池)、trae、buddy、kimicode - 插件:channels.lua(12 个平台登录/领额度/签到)、quota.lua(厂商额度探针) - 随之删除的 78 个渠道/额度专用测试与 3 份渠道文档 同时修掉的既有问题: - **硬编码第三方 OAuth client_secret**(Google Cloud Code 的 GOCSPX-…,两处) 随 channels.lua 一起消失。 - `deploy.sh` 的 `sync_plugins` 整段删除:它仍按"内置插件是发行物"的旧前提 把插件复制/覆盖进 plugin_dir,与"插件属运维资产、网关不安装不覆盖"的契约 冲突。现在部署脚本只同步适配器与二进制/配置。 - 适配器清单去掉已移除项;e2e 配置改用显式 `auto:` 链(`models.priority` 早已 移除,该测试此前已失效)。 保留(公开协议):openai / anthropic / gemini / deepseek / github / groq / mistral / ollama / qwen38 / sensenova = 10 个适配器;插件示例 billing / usage。 验证:go build -tags luajit ./... OK;internal/lua 21.4s、provider、core 51s、 gateway 122.8s、e2e 9.0s 全绿。
495 lines
21 KiB
Go
495 lines
21 KiB
Go
package lua
|
||
|
||
// 适配器共享前导(shared prelude)
|
||
//
|
||
// 问题:12 个 Lua 适配器里,**同一段代码被逐字复制多份**。实测(按钩子做
|
||
// 字节级哈希分组):
|
||
//
|
||
// transform_response github/groq/mistral 三份完全相同(2207B × 3)
|
||
// transform_stream_chunk github/groq/mistral 三份相同
|
||
// transform_error deepseek/github/groq 三份相同
|
||
//
|
||
// 合计 12680 字节的**完全重复**;再加上"近似相同"的部分(所有 OpenAI 兼容
|
||
// 适配器的 transform_request 都要剥 v2 私有控制字段、transform_response 都要
|
||
// 组装同一个 unified 骨架),复制面更大。
|
||
//
|
||
// openai.lua 自己的注释就写了这件事:
|
||
//
|
||
// (anthropic.lua carries the same helper; Lua adapters have no shared prelude.)
|
||
//
|
||
// —— safe_tool_id 在 openai.lua 与 anthropic.lua 各复制了一份。
|
||
//
|
||
// 参考插件用 src/openai-compat.ts(1196 行)解决同一问题:12 个 adapter 里
|
||
// 8 个 import 它。我们没有对应物。
|
||
//
|
||
// 做法:在**编译期把一份中性助手注入每个适配器脚本的前面**。这样:
|
||
//
|
||
// 1. 适配器源码不再复制粘贴,各平台只写**自己不同的那部分**;
|
||
// 2. 注入的是**中立协议助手**(剥内核私有字段、统一信封骨架、SSE 归类),
|
||
// 不含任何厂商名 —— 守住"内核零厂商名"纪律;
|
||
// 3. 已经自己实现了钩子的适配器**继续能跑**(前导只是往全局塞助手,
|
||
// 旧适配器不引用它们,行为逐字节不变)。
|
||
//
|
||
// ⚠️ 为什么注入而不是让适配器 require:适配器脚本是**独立编译**的(各自的
|
||
// golua 状态),没有模块系统(见 setupGlobals —— 只注册基础原语,没有
|
||
// package/require)。参考插件的 import 靠宿主打包器,我们没有那一层。
|
||
|
||
// adapterPreludeSrc 是注入每个适配器脚本前的中性助手库。
|
||
//
|
||
// 纪律:这里**不得**出现任何厂商名(openai 作为协议名是允许的 —— 它是中立的
|
||
// 行业协议,不是"某厂商")。平台差异留在各自的适配器文件里。
|
||
const adapterPreludeSrc = `
|
||
-- ── 共享前导(内核注入,勿在各适配器里重复定义)──────────────
|
||
--
|
||
-- 这些助手只做**协议层面的中立清理与组装**,不含任何平台专属规则。
|
||
-- 单个适配器可以覆盖同名全局(后定义者胜),但通常不该这么做 ——
|
||
-- 有差异时请在适配器里调用 ` + "`__prelude.<name>`" + ` 拿到中性实现再改。
|
||
|
||
local __prelude = {}
|
||
|
||
-- prelude.decode_body:宽松 JSON 解码。失败返回 nil(调用方回退原 body)。
|
||
-- 为什么不 pcall 在调用点展开:各适配器写法不一(pcall 的返回值形态、
|
||
-- 是否判 nil / type),统一成一处后行为一致。
|
||
function __prelude.decode_body(raw)
|
||
if type(raw) ~= "string" or raw == "" then return nil end
|
||
local ok, rec = pcall(json.decode, raw)
|
||
if not ok or type(rec) ~= "table" then return nil end
|
||
return rec
|
||
end
|
||
|
||
-- prelude.strip_control(req):剥掉内核私有控制字段。
|
||
--
|
||
-- 为什么是**内核**该管的事:这些字段(thinking / extra_body 的旧私有形态)
|
||
-- 是网关→适配器之间的**内部约定**,不是厂商概念。v2 改成中立 thinking 字段
|
||
-- 承载意图后,这批"删掉旧私有字段"的代码被复制进了每个适配器 —— 于是内核
|
||
-- 再改一次约定就要改 12 个文件。集中在这里,约定变化只改一处。
|
||
--
|
||
-- 各适配器仍可自行 req.thinking = nil(上游不支持思考时),那是**厂商语义**,
|
||
-- 留在各自文件里是对的;这里只负责"私有字段不该出现"。
|
||
function __prelude.strip_control(req)
|
||
if type(req) ~= "table" then return req end
|
||
req.extra_body = nil
|
||
if type(req.messages) == "table" then
|
||
for _, msg in ipairs(req.messages) do
|
||
if type(msg) == "table" then
|
||
msg.reasoning_content = nil
|
||
msg.reasoning = nil
|
||
end
|
||
end
|
||
end
|
||
return req
|
||
end
|
||
|
||
-- prelude.unified_skeleton():统一的响应信封骨架。
|
||
--
|
||
-- 内核下游只认这一个形状(content / finish_reason / token_usage /
|
||
-- tool_calls / reasoning)。空骨架集中定义,避免每个适配器各写一遍
|
||
-- (写法不一会在下游产生"某些字段缺失"的隐蔽差异)。
|
||
function __prelude.unified_skeleton()
|
||
return {
|
||
content = "",
|
||
reasoning = "",
|
||
finish_reason = "",
|
||
token_usage = { prompt = 0, completion = 0, total = 0 },
|
||
}
|
||
end
|
||
|
||
-- prelude.finish_reason(s):把上游的结束原因映射到中立取值。
|
||
-- 只处理**协议级**同义词;厂商特有的取值映射留在各自适配器。
|
||
function __prelude.finish_reason(s)
|
||
if type(s) ~= "string" or s == "" then return "" end
|
||
local t = string.lower(s)
|
||
if t == "stop" or t == "end_turn" then return "stop" end
|
||
if t == "length" or t == "max_tokens" then return "length" end
|
||
if t == "tool_calls" or t == "tool_use" or t == "function_call" then
|
||
return "tool_calls"
|
||
end
|
||
if t == "content_filter" then return "content_filter" end
|
||
return s
|
||
end
|
||
|
||
-- prelude.usage(rec):从 OpenAI 风格的 usage 块取计数(含 cache 细分)。
|
||
-- 各上游把缓存命中放在不同字段(prompt_cache_hit_tokens /
|
||
-- prompt_tokens_details.cached_tokens / cache_read_input_tokens),
|
||
-- 这是**协议族的已知变体**,不属于任何单一厂商。
|
||
function __prelude.usage(rec)
|
||
local out = { prompt = 0, completion = 0, total = 0 }
|
||
if type(rec) ~= "table" then return out end
|
||
local u = rec.usage or rec.token_usage or rec
|
||
if type(u) ~= "table" then return out end
|
||
out.prompt = tonumber(u.prompt_tokens or u.input_tokens or u.prompt) or 0
|
||
out.completion = tonumber(u.completion_tokens or u.output_tokens or u.completion) or 0
|
||
out.total = tonumber(u.total_tokens or u.total) or (out.prompt + out.completion)
|
||
local hit = tonumber(u.prompt_cache_hit_tokens
|
||
or (type(u.prompt_tokens_details) == "table" and u.prompt_tokens_details.cached_tokens)
|
||
or u.cache_read_input_tokens) or 0
|
||
local miss = tonumber(u.prompt_cache_miss_tokens) or 0
|
||
if hit > 0 or miss > 0 then
|
||
out.cache_hit = hit
|
||
out.cache_miss = miss
|
||
end
|
||
return out
|
||
end
|
||
|
||
-- prelude.choices(rec):取 choices 数组的首元素(OpenAI 兼容族的统一位置)。
|
||
function __prelude.choices(rec)
|
||
if type(rec) ~= "table" then return nil end
|
||
local c = rec.choices
|
||
if type(c) ~= "table" or #c == 0 then return nil end
|
||
return c[1]
|
||
end
|
||
|
||
-- prelude.tool_id(id):把工具调用 id 规整到 Anthropic 可接受的形式
|
||
-- ^[a-zA-Z0-9_-]{1,64}$。
|
||
--
|
||
-- 为什么在共享层:本网关是**扇出路由器** —— 一个 OpenAI 兼容模型产生
|
||
-- "bash:0" 这样的 id 会被回放到每个 Claude 上游,一个坏 id 就能让整条
|
||
-- AUTO 链路失败。这是**路由器的性质**,不是某个厂商的规则,所以归前导。
|
||
function __prelude.tool_id(id)
|
||
if type(id) ~= "string" or id == "" then return id end
|
||
local clean = string.gsub(id, "[^A-Za-z0-9_-]", "_")
|
||
if clean == id and #clean <= 64 then return clean end
|
||
-- 保留原 id 的 8 位摘要,避免不同 id 坍缩成同一个
|
||
--("a:b" 与 "a_b" 会归一化成同一个,导致工具调用失配)
|
||
local digest = string.sub(sha256_hex(id), 1, 8)
|
||
local room = 64 - 1 - 8
|
||
if #clean > room then clean = string.sub(clean, 1, room) end
|
||
return clean .. "_" .. digest
|
||
end
|
||
|
||
-- prelude.openai_stream_chunk(raw):标准 OpenAI SSE chunk → 统一信封(字符串)。
|
||
--
|
||
-- 这段代码在 github / groq / kimicode / mistral 四个适配器里**逐字节相同**
|
||
-- (实测 2211B × 4)。它是 OpenAI 协议族的通用形态,不属于任何厂商。
|
||
--
|
||
-- 要点(都写进注释,因为每一条都踩过):
|
||
-- * usage 可能附着在 choices 为空的 chunk 上(OpenAI 的末段 usage chunk);
|
||
-- 此时**不是**内容/结束信号,返回 {usage=…, done=false} 让内核自行合并,
|
||
-- 不能当成终结帧(否则真正的 finish_reason 会被无原因的空帧覆盖)。
|
||
-- * usage 的 key 必须匹配 Go 侧 TokenUsage 的 json tag
|
||
-- (prompt/completion/total),内核再统一重发 *_tokens。
|
||
-- * 缓存命中在协议族里有两种写法:prompt_tokens_details.cached_tokens
|
||
-- 与 prompt_cache_hit_tokens —— 两者都要认,并归一成
|
||
-- prompt_tokens_details.cached_tokens 供下游消费。
|
||
-- * reasoning_content 是协议族公认的思考字段名,原样透传。
|
||
function __prelude.openai_stream_chunk(raw)
|
||
local chunk = __prelude.decode_body(raw)
|
||
if chunk == nil then return "" end
|
||
|
||
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,
|
||
}
|
||
local details = chunk.usage.prompt_tokens_details
|
||
if type(details) == "table" and details.cached_tokens ~= nil then
|
||
uses.prompt_tokens_details = { cached_tokens = details.cached_tokens }
|
||
elseif (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
|
||
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_cache_hit_tokens }
|
||
end
|
||
end
|
||
|
||
local choices = chunk.choices
|
||
if type(choices) ~= "table" or #choices == 0 then
|
||
if uses ~= nil then
|
||
return json.encode({ usage = uses, done = false })
|
||
end
|
||
return ""
|
||
end
|
||
|
||
local delta = choices[1].delta or {}
|
||
local fr = 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
|
||
if delta.reasoning_content then unified.reasoning_content = delta.reasoning_content end
|
||
if delta.tool_calls then
|
||
-- 与非流式同规则规整 id(两条路径必须一致,见 openai_response 的注释)
|
||
local tcs = {}
|
||
for _, tc in ipairs(delta.tool_calls) do
|
||
local copy = {}
|
||
for k, v in pairs(tc) do copy[k] = v end
|
||
copy.id = __prelude.tool_id(tc.id)
|
||
tcs[#tcs + 1] = copy
|
||
end
|
||
unified.tool_calls = tcs
|
||
end
|
||
return json.encode(unified)
|
||
end
|
||
|
||
-- prelude.openai_response(raw):非流式 OpenAI 响应 → 统一信封(字符串)。
|
||
--
|
||
-- 这段实现与 openai / groq / kimicode / mistral / github 五个适配器里原先
|
||
-- 各自的 transform_response **逐字节等价**(实测那五份本就完全相同,
|
||
-- diff 为空,各 2206B)。抽到前导后,各适配器只剩一行调用。
|
||
--
|
||
-- 它做了三件事,每件都有理由:
|
||
-- 1. usage 归一(含**两种**缓存命中写法 → prompt_tokens_details.cached_tokens)
|
||
-- 2. tool_calls 的 arguments **从 JSON 字符串解成表**(下游要结构化对象;
|
||
-- 解不开时给空表而不是原字符串,否则下游会拿到一个类型不定的字段)
|
||
-- 3. finish_reason 原样带出(本族不做同义词映射,与流式路径一致)
|
||
function __prelude.openai_response(raw)
|
||
local resp = __prelude.decode_body(raw)
|
||
if resp == nil then return raw end
|
||
|
||
local unified = __prelude.unified_skeleton()
|
||
|
||
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
|
||
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 ""
|
||
-- reasoning_content 原样带出(协议族公认的思考字段)
|
||
if ch.message.reasoning_content then
|
||
unified.reasoning_content = ch.message.reasoning_content
|
||
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, {
|
||
-- ⚠️ 必须过 tool_id 规整:本网关是扇出路由器,一个非法 id
|
||
-- (如 "bash:0")会被回放到每个 Claude 上游 → 整条 AUTO 链失败。
|
||
-- 且**流式与非流式必须用同一规则**,否则同一请求两条路径产生
|
||
-- 不同 id(实测踩过:流式 "bash_0_40248ca0" vs 非流式 "bash:0")。
|
||
id = __prelude.tool_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
|
||
|
||
-- prelude.openai_error(status, body):把上游错误体抽成一句人话。
|
||
--
|
||
-- ⚠️ 这里放的是**最全的那一版**(原先只有 openai.lua 有,1580B),不是各
|
||
-- 适配器里最小的一版(293B)。第一版重构我抽了最小实现,结果打挂了 6 个既有
|
||
-- 测试(TestOpenAITransformErrorEnvelopes 的 6 个子用例 + TestToolID…)——
|
||
-- 因为最小版只认 {error:{message}},丢掉了 HTML 错误页、裸文本、扁平
|
||
-- {code,message}、FastAPI {detail} 等 6 种形状。
|
||
--
|
||
-- 教训:抽公共代码要抽**最全的实现**,并让所有调用方升到它;抽最小实现是
|
||
-- 静默降级(不报错、只是少认几种错误体)。
|
||
--
|
||
-- 覆盖的形状(都有实测来源):
|
||
-- 1. {error:{message}} 标准 OpenAI 信封
|
||
-- 2. {error:"..."} 错误是裸字符串
|
||
-- 3. {code, message} 扁平信封(多家 OpenAI 兼容网关)
|
||
-- 4. {detail:"..."} FastAPI 风格
|
||
-- 5. <title>…</title> nginx HTML 错误页(413 Request Entity Too Large)
|
||
-- 6. 裸文本 取首个非空行,截断到 200 字符
|
||
-- 返回 nil 表示"不识别",内核回退通用文案。
|
||
function __prelude.openai_error(status, body)
|
||
local resp = __prelude.decode_body(body)
|
||
if resp == nil then
|
||
-- 非 JSON:HTML 错误页取 <title>,否则取首个非空非标签行
|
||
local title = string.match(body or "", "<title>(.-)</title>")
|
||
if title and title ~= "" then return title end
|
||
local line = string.match(body or "", "^%s*([^\r\n]+)")
|
||
if line and line ~= "" and not string.match(line, "^<") then
|
||
return string.sub(line, 1, 200)
|
||
end
|
||
return nil
|
||
end
|
||
|
||
local e = resp.error
|
||
if type(e) == "table" and type(e.message) == "string" then
|
||
return e.message
|
||
end
|
||
if type(e) == "string" then return e end
|
||
|
||
if type(resp.message) == "string" and resp.message ~= "" then
|
||
if resp.code ~= nil then
|
||
return tostring(resp.code) .. ": " .. resp.message
|
||
end
|
||
return resp.message
|
||
end
|
||
|
||
if type(resp.detail) == "string" and resp.detail ~= "" then
|
||
return resp.detail
|
||
end
|
||
|
||
-- 10. 腾讯 codebuddy 的**三层嵌套**:{error:{data:{code,msg,requestId}}}。
|
||
-- 实测 429 code=14018「额度已用尽,请访问以下链接,购买加量包…」——
|
||
-- 此前落到"不识别",内核只回通用文案,用户看不出是额度用尽
|
||
-- (日志里是 "transform_error returned no reason for this shape")。
|
||
local ed = type(resp.error) == "table" and resp.error.data or nil
|
||
if type(ed) == "table" and type(ed.msg) == "string" and ed.msg ~= "" then
|
||
if ed.code ~= nil then return tostring(ed.code) .. ": " .. ed.msg end
|
||
return ed.msg
|
||
end
|
||
|
||
-- 7/8/9. 网关与腾讯系形状(实测漏过,见函数头注释)。
|
||
-- error_code 与 error_msg 同现时拼起来 —— 只看 msg 会丢掉可分类的码
|
||
-- (参考 llm-adapter.ts:531 把两者都放进 parts 再 join)。
|
||
local msg = nil
|
||
if type(resp.error_msg) == "string" and resp.error_msg ~= "" then
|
||
msg = resp.error_msg
|
||
elseif type(resp.msg) == "string" and resp.msg ~= "" then
|
||
msg = resp.msg
|
||
end
|
||
-- 腾讯系(buddy/workbuddy)用 msg + displayMsg.{zh,en},且**没有** message。
|
||
-- 参考 buddy-adapter.ts:576 明确:本地化文案最可读,排在**最前**。
|
||
local localized = nil
|
||
if type(resp.displayMsg) == "table" then
|
||
local dm = resp.displayMsg
|
||
if type(dm.zh) == "string" and dm.zh ~= "" then localized = dm.zh
|
||
elseif type(dm.en) == "string" and dm.en ~= "" then localized = dm.en end
|
||
end
|
||
if msg then
|
||
local code = nil
|
||
if type(resp.error_code) == "string" and resp.error_code ~= "" then
|
||
code = resp.error_code
|
||
elseif resp.code ~= nil and type(resp.code) ~= "string" then
|
||
code = tostring(resp.code)
|
||
end
|
||
local detail = code and (code .. ": " .. msg) or msg
|
||
if localized then return localized .. " " .. detail end
|
||
return detail
|
||
end
|
||
if localized then return localized end
|
||
if type(resp.error_code) == "string" and resp.error_code ~= "" then
|
||
return resp.error_code
|
||
end
|
||
|
||
return nil
|
||
end
|
||
|
||
-- prelude.gemini_response(raw):Gemini 非流式响应 → 统一信封(字符串)。
|
||
--
|
||
-- 与 OpenAI 族的差别在**字段名**(candidates/content/parts、usageMetadata),
|
||
-- 这是协议族差异而非厂商差异,归前导。
|
||
function __prelude.gemini_response(raw)
|
||
local resp = __prelude.decode_body(raw)
|
||
if resp == nil then return raw end
|
||
local unified = __prelude.unified_skeleton()
|
||
local cands = resp.candidates
|
||
if type(cands) == "table" and #cands > 0 then
|
||
local c = cands[1]
|
||
local text, tcs = "", {}
|
||
local parts = (c.content and c.content.parts) or {}
|
||
for _, p in ipairs(parts) do
|
||
if type(p) == "table" then
|
||
if type(p.text) == "string" then text = text .. p.text end
|
||
if type(p.functionCall) == "table" then
|
||
tcs[#tcs + 1] = {
|
||
id = p.functionCall.name or ("call_" .. #tcs),
|
||
type = "function",
|
||
name = p.functionCall.name,
|
||
arguments = p.functionCall.args or {},
|
||
}
|
||
end
|
||
end
|
||
end
|
||
unified.content = text
|
||
if #tcs > 0 then unified.tool_calls = tcs end
|
||
-- finishReason: STOP/MAX_TOKENS/… → 中立取值
|
||
unified.finish_reason = __prelude.finish_reason(c.finishReason)
|
||
end
|
||
local u = resp.usageMetadata
|
||
if type(u) == "table" then
|
||
unified.token_usage = {
|
||
prompt = u.promptTokenCount or 0,
|
||
completion = u.candidatesTokenCount or 0,
|
||
total = u.totalTokenCount or 0,
|
||
}
|
||
if u.cachedContentTokenCount then
|
||
unified.token_usage.prompt_tokens_details = { cached_tokens = u.cachedContentTokenCount }
|
||
end
|
||
end
|
||
return json.encode(unified)
|
||
end
|
||
|
||
-- prelude.gemini_stream_chunk(raw):Gemini 流式分片 → 统一信封(字符串)。
|
||
function __prelude.gemini_stream_chunk(raw)
|
||
local resp = __prelude.decode_body(raw)
|
||
if resp == nil then return "" end
|
||
local cands = resp.candidates
|
||
if type(cands) ~= "table" or #cands == 0 then
|
||
-- 可能是只带 usageMetadata 的末段
|
||
local u = resp.usageMetadata
|
||
if type(u) == "table" then
|
||
return json.encode({ done = false, usage = {
|
||
prompt = u.promptTokenCount or 0,
|
||
completion = u.candidatesTokenCount or 0,
|
||
total = u.totalTokenCount or 0,
|
||
} })
|
||
end
|
||
return ""
|
||
end
|
||
local c = cands[1]
|
||
local text, tcs = "", {}
|
||
for _, p in ipairs((c.content and c.content.parts) or {}) do
|
||
if type(p) == "table" then
|
||
if type(p.text) == "string" then text = text .. p.text end
|
||
if type(p.functionCall) == "table" then
|
||
tcs[#tcs + 1] = { index = #tcs, id = p.functionCall.name or "call",
|
||
type = "function", name = p.functionCall.name,
|
||
arguments = p.functionCall.args or {} }
|
||
end
|
||
end
|
||
end
|
||
local finish = c.finishReason
|
||
local unified = { content = text, done = (finish ~= nil and finish ~= "") }
|
||
if finish and finish ~= "" then unified.finish_reason = __prelude.finish_reason(finish) end
|
||
if #tcs > 0 then
|
||
for i, tc in ipairs(tcs) do tcs[i].index = i - 1 end
|
||
unified.tool_calls = tcs
|
||
end
|
||
return json.encode(unified)
|
||
end
|
||
|
||
-- prelude.derive_key:由 base 派生一个稳定的、长度受限的标识(如 client_session)。
|
||
function __prelude.derive_key(base, n)
|
||
n = n or 32
|
||
return string.sub(sha256_hex(tostring(base or "")), 1, n)
|
||
end
|
||
|
||
_G.__prelude = __prelude
|
||
-- ── 共享前导结束 ────────────────────────────────────────────
|
||
|
||
`
|
||
|
||
// withAdapterPrelude 把共享前导拼在适配器源码之前。
|
||
//
|
||
// 为什么要拼到**源码字符串**而不是运行时注入全局:适配器是通过 inspectScript
|
||
// 和 worker 两条路径各自 DoString 编译的,拼源码是唯一能同时覆盖两条路径、
|
||
// 且不改变 Lua 作用域语义的做法(运行时 SetGlobal 会在热重载/池重建时丢失)。
|
||
func withAdapterPrelude(code string) string {
|
||
return adapterPreludeSrc + code
|
||
}
|