diff --git a/internal/lua/adapter_prelude.go b/internal/lua/adapter_prelude.go new file mode 100644 index 0000000..8c83475 --- /dev/null +++ b/internal/lua/adapter_prelude.go @@ -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.`" + ` 拿到中性实现再改。 + +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 +} diff --git a/internal/lua/adapter_prelude_test.go b/internal/lua/adapter_prelude_test.go new file mode 100644 index 0000000..432e823 --- /dev/null +++ b/internal/lua/adapter_prelude_test.go @@ -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) + } +} diff --git a/internal/lua/vm.go b/internal/lua/vm.go index f8ca65c..3dfb7fe 100644 --- a/internal/lua/vm.go +++ b/internal/lua/vm.go @@ -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