mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
问题(实测证据,按钩子做字节级哈希分组): 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.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), 我们没有对应物。本提交补上这一层。 做法:**编译期把一份中性助手库拼在适配器脚本前面**,注入点在 LoadAdapterSource —— 适配器有三个编译点(worker boot / HasFn 探针 / inspectScript),全部源自这里存进 pool 的 script 文本,拼一处即全覆盖; 且不像运行时 SetGlobal 那样会在热重载/池重建时丢失。 助手只做**协议层面的中立清理与组装**,不含任何厂商名(守住内核零厂商名 纪律):decode_body / strip_control(剥内核私有控制字段 —— 那是网关内部 约定,v2 改一次就要改 12 个文件)/ unified_skeleton / finish_reason / usage(含 cache 细分,属协议族已知变体)/ choices / tool_id(路由器性质: 坏 id 会被回放到每个 Claude 上游,一票否决整条 AUTO 链)/ derive_key。 判据两条: - TestAdapterPreludeInjectedAndCorrect:不只断言存在,抽查**语义值** (end_turn→stop、tool_use→tool_calls、合法 id 原样透传、非法 id 带摘要 改写、cache_hit 从 prompt_cache_hit_tokens 取) - TestAdapterPreludeDoesNotChangeExistingAdapters:旧适配器(自己实现全部 钩子、不引用前导)输出逐字节不变 回归:internal/lua + internal/provider 全绿(16 个适配器行为不变)。
179 lines
8.2 KiB
Go
179 lines
8.2 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.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
|
||
}
|