Files
ModelRouter/internal/lua/adapter_prelude.go
JianFeeeee 8f2c5572e7 chore!: 移除厂商私有协议适配器与渠道业务插件(合规清理)
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 全绿。
2026-10-27 09:25:58 +08:00

495 lines
21 KiB
Go
Raw 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/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
}