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.`" + ` 拿到中性实现再改。 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. … 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 错误页取 ,否则取首个非空非标签行 local title = string.match(body or "", "<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 }