mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
## 发现路径:读生产日志(不是测试报红)
```
unhandled upstream error body (adapter "openai" lacks transform_error):
status=404 body={"error_msg":"404 Route Not Found"}
unhandled upstream error body (adapter "buddy" lacks transform_error):
status=400 body={"code":11101,"msg":"Non-stream chat request is currently not supported",...}
```
### ★ 缺陷 A:错误体形状漏了「网关」与「腾讯系」两类
`__prelude.openai_error()` 只认 `error.message` / `error`(裸串) / `{code,message}`
/ `detail` / HTML `<title>` / 裸文本 六种,**没有** `error_msg`(华为/APISIX 网关)
与 `msg`(腾讯系用 msg 而非 message)⇒ 返回 nil
⇒ 客户端只看到 `api error 404: unknown error`,**而 body 里明明有可用文案**。
修法照参考(llm-adapter.ts:524 `errorDetail()` 显式认 `error_code`/`error_msg`;
buddy-adapter.ts:576 认 `msg` + `displayMsg.{zh,en}`):
- 新增 `{error_msg}` / `{msg}` / `{error_code,+error_msg}` 三种形状;
- 新增 `displayMsg.{zh,en}` 本地化文案,**排在最前**(参考注释明说"本地化文案
最可读";其早期实现只读 `error.*`/`message` ⇒ 全部落空 ⇒ 把 291 字符原始
报文糊进错误提示,而服务端早已备好中文说明)。
覆盖 7 个用共享前导的适配器:buddy/deepseek/github/groq/kimicode/openai/sensenova。
### ★ 缺陷 B:内核那句日志会把人带偏
措辞是 `adapter %q lacks transform_error`,但 **openai.lua:139 与 buddy.lua:166
都明明定义了该钩子** —— 真实原因只是它**返回了 nil**(认不出那个形状)。
我照着这句话去适配器里找"有没有这个函数",查了几轮才发现函数在、是返回值空。
修法:内核直接查事实(`p.vm.HasFn(adapter, "transform_error")`),
两种原因**分开措辞**,不再猜。
## 判据
`internal/lua/adapter_prelude_error_shapes_test.go`:
- 12 个形状子用例(4 个新增 + 6 个回归 + 2 个 displayMsg),
**装载仓库里真实的 openai.lua**(含共享前导),不是自造桩 ——
第一版用空 VM 跑,12 个全 FAIL(`adapter openai not loaded`),
那等于什么也没验。
- 配对判据 `...StillReportsUnknownShapes`:真正认不出的形状仍须 `ok=false`,
防止"为了让上面通过就把『抽不出』和『没这个钩子』混为一谈"。
- 变异:删掉新增的 error_msg/msg 分支 ⇒ APISIX 用例报红。
## 备注
`anthropic/gemini/mistral/ollama/opencodezen/qwen38/trae` 有自己写的
`transform_error`,不走共享前导,**未获得**本次新增形状(各自协议的错误体
本就不同)。此处只修共享前导的 7 个。
485 lines
21 KiB
Go
485 lines
21 KiB
Go
package lua
|
||
|
||
// 适配器共享前导(shared prelude)
|
||
//
|
||
// 问题:12 个 Lua 适配器里,**同一段代码被逐字复制多份**。实测(按钩子做
|
||
// 字节级哈希分组):
|
||
//
|
||
// transform_response github/groq/mistral 三份完全相同(2207B × 3)
|
||
// transform_stream_chunk github/groq/kimicode/mistral 四份相同(2211B × 4)
|
||
// transform_error deepseek/github/groq/kimicode 四份相同(293B × 4)
|
||
//
|
||
// 合计 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
|
||
|
||
-- 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
|
||
}
|