Files
ModelRouter/internal/lua/adapter_prelude.go
JianFeeeee aea1a2c48d fix(provider): 上游错误文案别再丢 —— 补网关/腾讯错误体形状 + 修误导性日志
## 发现路径:读生产日志(不是测试报红)
```
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 个。
2026-10-17 00:37:52 +08:00

485 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}