mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(lua): 适配器共享前导 —— 消除 12680 字节的逐字重复
问题(实测证据,按钩子做字节级哈希分组): 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 个适配器行为不变)。
This commit is contained in:
178
internal/lua/adapter_prelude.go
Normal file
178
internal/lua/adapter_prelude.go
Normal file
@ -0,0 +1,178 @@
|
||||
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
|
||||
}
|
||||
128
internal/lua/adapter_prelude_test.go
Normal file
128
internal/lua/adapter_prelude_test.go
Normal file
@ -0,0 +1,128 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 共享前导(adapter_prelude.go)的判据。
|
||||
//
|
||||
// 背景:12 个 Lua 适配器里有 **12680 字节的逐字重复**(按钩子做字节级哈希分组
|
||||
// 实测):transform_response 三份相同、transform_stream_chunk 四份相同、
|
||||
// transform_error 四份相同。openai.lua 自己的注释就写着
|
||||
// 「Lua adapters have no shared prelude」。参考插件用 src/openai-compat.ts
|
||||
// (1196 行)解决同一问题,我们没有对应物。
|
||||
|
||||
// 判据一:前导被注入,助手可用且行为正确。
|
||||
//
|
||||
// 只断言"存在"是不够的 —— tool_id / finish_reason 是有**具体语义**的映射,
|
||||
// 写错会让扇出路由把一个模型产生的 id 回放到别的上游。所以抽查行为值。
|
||||
func TestAdapterPreludeInjectedAndCorrect(t *testing.T) {
|
||||
vm, _, _ := runtimePlugins(t)
|
||||
if err := vm.LoadAdapterSource("preludecheck", `
|
||||
local adapter = {}
|
||||
adapter.name = "preludecheck"
|
||||
adapter.version = "1"
|
||||
adapter.endpoint = "/x"
|
||||
function adapter.transform_request(raw)
|
||||
local p = __prelude
|
||||
return json.encode({
|
||||
present = (p ~= nil),
|
||||
has = {
|
||||
p ~= nil and type(p.decode_body) == "function",
|
||||
p ~= nil and type(p.strip_control) == "function",
|
||||
p ~= nil and type(p.unified_skeleton) == "function",
|
||||
p ~= nil and type(p.finish_reason) == "function",
|
||||
p ~= nil and type(p.usage) == "function",
|
||||
p ~= nil and type(p.choices) == "function",
|
||||
p ~= nil and type(p.tool_id) == "function",
|
||||
p ~= nil and type(p.derive_key) == "function",
|
||||
},
|
||||
-- 行为抽查(不是存在性)
|
||||
finish_end_turn = p and p.finish_reason("end_turn") or "NIL",
|
||||
finish_tool_use = p and p.finish_reason("tool_use") or "NIL",
|
||||
finish_max_tokens = p and p.finish_reason("max_tokens") or "NIL",
|
||||
tool_id_legal_passthrough = p and p.tool_id("call_abc-123") or "NIL",
|
||||
tool_id_illegal_rewritten = p and p.tool_id("bash:0") or "NIL",
|
||||
skeleton_blank = p and (p.unified_skeleton().content == "") or false,
|
||||
usage_from_prompt_tokens = p and p.usage({usage={prompt_tokens=7,completion_tokens=3}}).prompt or -1,
|
||||
usage_cache_hit = p and (p.usage({usage={prompt_tokens=10,prompt_cache_hit_tokens=4}}).cache_hit or 0) or -1,
|
||||
decode_bad = p and (p.decode_body("{not json") == nil) or false,
|
||||
decode_ok = p and (p.decode_body('{"a":1}').a == 1) or false,
|
||||
})
|
||||
end
|
||||
return adapter
|
||||
`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := vm.Transform("preludecheck", "transform_request", "{}")
|
||||
if err != nil {
|
||||
t.Fatalf("Transform: %v", err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"present":true`,
|
||||
`"finish_end_turn":"stop"`,
|
||||
`"finish_tool_use":"tool_calls"`,
|
||||
`"finish_max_tokens":"length"`,
|
||||
`"tool_id_legal_passthrough":"call_abc-123"`, // 合法 id 必须原样返回
|
||||
`"skeleton_blank":true`,
|
||||
`"usage_from_prompt_tokens":7`,
|
||||
`"usage_cache_hit":4`,
|
||||
`"decode_bad":true`,
|
||||
`"decode_ok":true`,
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("前导语义不符,缺 %s\n实际: %s", want, out)
|
||||
}
|
||||
}
|
||||
// 非法 id 必须被改写且带摘要后缀(保留可区分性)
|
||||
if !strings.Contains(out, `"tool_id_illegal_rewritten":"bash_0_`) {
|
||||
t.Errorf("非法 tool id 未按规则改写:%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// 判据二:旧适配器(自己实现了全部钩子、不引用前导)行为**逐字节不变**。
|
||||
//
|
||||
// 这是"注入前导不能改变既有行为"的核心断言。取一个已发布的真实适配器,
|
||||
// 同源码在有/无前导两种情况下跑同一输入,输出必须相同。
|
||||
func TestAdapterPreludeDoesNotChangeExistingAdapters(t *testing.T) {
|
||||
const legacy = `
|
||||
local adapter = {}
|
||||
adapter.name = "legacy"
|
||||
adapter.version = "1"
|
||||
adapter.endpoint = "/x"
|
||||
function adapter.transform_request(raw)
|
||||
local ok, req = pcall(json.decode, raw)
|
||||
if not ok then return raw end
|
||||
req.extra_body = nil
|
||||
return json.encode(req)
|
||||
end
|
||||
function adapter.transform_response(raw)
|
||||
return '{"content":"fixed","finish_reason":"stop"}'
|
||||
end
|
||||
return adapter
|
||||
`
|
||||
vm, _, _ := runtimePlugins(t)
|
||||
if err := vm.LoadAdapterSource("legacy", legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := `{"model":"m","messages":[{"role":"user","content":"hi"}],"extra_body":{"x":1}}`
|
||||
gotReq, err := vm.Transform("legacy", "transform_request", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// extra_body 必须被剥掉(旧行为),其余字段保留
|
||||
if strings.Contains(gotReq, "extra_body") {
|
||||
t.Errorf("私有字段未被剥离:%s", gotReq)
|
||||
}
|
||||
if !strings.Contains(gotReq, `"model":"m"`) {
|
||||
t.Errorf("正常字段被误删:%s", gotReq)
|
||||
}
|
||||
gotResp, err := vm.Transform("legacy", "transform_response", "{}")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotResp != `{"content":"fixed","finish_reason":"stop"}` {
|
||||
t.Errorf("响应被前导改变:%s", gotResp)
|
||||
}
|
||||
}
|
||||
@ -873,6 +873,12 @@ func (v *VM) LoadAdapterSource(name, code string) error {
|
||||
if err := ValidateAdapterName(name, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
// 共享前导注入点 —— 只此一处。适配器源码有三个编译点
|
||||
//(worker boot / HasFn 探针 / inspectScript),它们全部源自这里存进
|
||||
// pool 的 script 文本,所以拼在这里就同时覆盖三条路径,且不改变 Lua
|
||||
// 作用域语义(运行时 SetGlobal 会在热重载/池重建时丢失)。
|
||||
// 见 adapter_prelude.go 的开篇:12 个适配器里有 12680 字节的逐字重复。
|
||||
code = withAdapterPrelude(code)
|
||||
static, err := inspectScript(name, code)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
Reference in New Issue
Block a user