chore(main): 回流 release/v2.0.x(v2.0.12)—— 内容对齐,main 无独有内容

按发布纪律回流。**注意这是内容对齐而非 merge**,理由与依据:

1. 纪律禁止 merge 回主干,因为 merge 会把发布分支的**版本号变更**带进 main。
   本项目版本号**只由 `git describe --tags` 推导**(Makefile VERSION),
   仓库里没有任何文件存放版本号 ⇒ 回流不存在版本污染风险。
2. 逐项核对确认 **main 是 release 的内容严格子集**:
   - `main` 独有的**文件**:0 个;
   - `internal/lua/plugins.go` / `internal/core/kernel_actions.go` 等内核文件
     main 独有行数:0;
   - `deploy.sh` 那 14 行只是 emoji 清理(release 刻意去掉了 emoji);
   - `channels.lua` 那 333 行是**旧版内容**(旧 exports 表、旧模型名单)。
   - main 独有的 9 个提交按 patch-id **全部已在 release 中等效存在**
     (`git cherry` 逐条列出均为 `-`,即已合入)。
3. 故回流 = 把 release 的内容带到 main,等价于"只捡拾修复、不带版本号",
   且不会丢任何 main 独有的修复。

本次带回 v2.0.12 的全部内容(37 个提交):
- 源串行 + 按模型节流(照 model-gate)
- 已失效模型存储 + 远端目录闸门
- 余额分档选号
- 模型能力元数据(models.dev,isFree 权威判定)
- **凭据续期修复**(此前恒报 ok:true 却一个请求都不发)
- 通用件 TTL 缓存 + 串行锁;account_test/account_adopt/account_delete 修正
- 内核级:出站授权持久化重启后不生效、`__llmsproxy_plugin_dir` ground truth

校验:`git diff release/v2.0.x` = 0 行;`go build -tags luajit ./...` 通过。
This commit is contained in:
JianFeeeee
2026-10-14 16:52:58 +08:00
parent 0004545190
commit 9bec158d2e
94 changed files with 11409 additions and 974 deletions

View File

@ -0,0 +1,451 @@
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.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
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
}

View File

@ -0,0 +1,123 @@
package lua
import (
"strings"
"testing"
)
// 前导重构的**等价性判据**:把重复实现抽进前导后,输出必须逐字节不变。
//
// 为什么需要它:抽公共代码最常见的失败是"看起来一样、实际差一个字段"——
// 比如 usage 的缓存命中只认了一种写法、tool_calls 的 arguments 忘了解码、
// usage-only chunk 被当成终结帧。这类差异不会报错,只会让下游少一个字段。
//
// 期望值是重构前那 5 个适配器(openai/groq/kimicode/mistral/github 的
// transform_response 本就逐字节相同)对固定输入的真实输出,先抓 golden 再比。
func TestPreludeRefactorIsByteIdentical(t *testing.T) {
vm, _, _ := runtimePlugins(t)
for _, name := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
src, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
if err != nil {
t.Fatalf("读内嵌适配器 %s: %v", name, err)
}
if err := vm.LoadAdapterSource(name, string(src)); err != nil {
t.Fatalf("%s: %v", name, err)
}
}
// 非流式:工具调用 + 两种缓存命中写法同时出现
respIn := `{"choices":[{"message":{"content":"hi","tool_calls":[{"id":"c1","type":"function",` +
`"function":{"name":"f","arguments":"{\"a\":1}"}}]},"finish_reason":"tool_calls"}],` +
`"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15,` +
`"prompt_cache_hit_tokens":4,"prompt_cache_miss_tokens":6}}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_response", respIn)
if err != nil {
t.Fatalf("%s transform_response: %v", n, err)
}
for _, want := range []string{
`"content":"hi"`,
`"finish_reason":"tool_calls"`,
`"prompt":10`, `"completion":5`, `"total":15`,
// 缓存命中必须归一成 prompt_tokens_details.cached_tokens
`"prompt_tokens_details":{"cached_tokens":4}`,
`"prompt_cache_hit_tokens":4`,
// tool_calls 的 arguments 必须**解成表**(不是原字符串)
`"arguments":{"a":1}`,
`"name":"f"`,
} {
if !strings.Contains(out, want) {
t.Errorf("%s: 输出缺 %s\n实际: %s", n, want, out)
}
}
// arguments 绝不能被留下原样的 JSON 字符串
if strings.Contains(out, `"arguments":"{\"a\":1}"`) {
t.Errorf("%s: arguments 未被解码成表:%s", n, out)
}
}
// 流式:内容 + reasoning + tool_calls 同帧
streamIn := `{"choices":[{"delta":{"content":"x","reasoning_content":"r",` +
`"tool_calls":[{"index":0,"id":"c1","function":{"name":"f","arguments":"{}"}}]},` +
`"finish_reason":null}]}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_stream_chunk", streamIn)
if err != nil {
t.Fatalf("%s stream: %v", n, err)
}
for _, want := range []string{
`"content":"x"`, `"reasoning_content":"r"`, `"done":false`,
`"tool_calls"`, `"index":0`,
} {
if !strings.Contains(out, want) {
t.Errorf("%s stream 缺 %s\n实际: %s", n, want, out)
}
}
}
// usage-only chunk(choices 为空)**不得**被当成终结帧
usageOnly := `{"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":0,"total_tokens":3}}`
for _, n := range []string{"openai", "groq", "kimicode", "mistral", "github"} {
out, err := vm.Transform(n, "transform_stream_chunk", usageOnly)
if err != nil {
t.Fatalf("%s usage-only: %v", n, err)
}
if !strings.Contains(out, `"done":false`) {
t.Errorf("%s: usage-only chunk 必须是 done:false(照参考:它不是终结信号,"+
"当终结会覆盖真正的 finish_reason)\n实际: %s", n, out)
}
if !strings.Contains(out, `"prompt":3`) {
t.Errorf("%s: usage-only chunk 的 usage 丢了:%s", n, out)
}
}
// 错误体抽取:OpenAI 风格 {error:{message}} → 消息本身
// (这段在 6 个适配器里原本逐字节相同)
for _, n := range []string{"deepseek", "github", "groq", "kimicode", "openai", "sensenova"} {
src, err := bundledAdapters.ReadFile("adapters/" + n + ".lua")
if err != nil {
t.Fatalf("读 %s: %v", n, err)
}
if err := vm.LoadAdapterSource(n, string(src)); err != nil {
t.Fatalf("%s: %v", n, err)
}
reason, ok, err := vm.TransformError(n, 429, `{"error":{"message":"rate limited"}}`)
if err != nil {
t.Fatalf("%s TransformError: %v", n, err)
}
if !ok || reason != "rate limited" {
t.Errorf("%s: 错误抽取结果 %q ok=%v,期望 \"rate limited\"", n, reason, ok)
}
// 非 OpenAI 形状必须返回 nil(让内核回退通用文案),不能瞎猜
if _, ok, _ := vm.TransformError(n, 500, `{"foo":"bar"}`); ok {
t.Errorf("%s: 非 OpenAI 形状应返回 ok=false", n)
}
}
// 坏 JSON 必须原样回退(不能吞成空)
if out, err := vm.Transform("openai", "transform_response", "not json"); err != nil {
t.Fatalf("bad json: %v", err)
} else if out != "not json" {
t.Errorf("坏 JSON 应原样回退,实得: %s", out)
}
}

View 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)
}
}

View File

@ -146,13 +146,9 @@ end
-- 错误收敛:DeepSeek 走标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -26,122 +26,22 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
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 ""
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, {
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)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
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,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_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
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.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
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:GitHub Models 走标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -25,122 +25,22 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
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 ""
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, {
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)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
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,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_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
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.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
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:Groq 走标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -58,122 +58,22 @@ function adapter.build_headers(meta)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
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 ""
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, {
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)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
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,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_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
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.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
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:kimi 网关为 OpenAI 风格 {error:{message,...}}
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) == "table" and type(e.message) == "string" then
return e.message
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -25,111 +25,15 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
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 ""
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, {
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)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
local ok, chunk = pcall(json.decode, raw_chunk)
if not ok then return "" end
-- OpenAI-style streams may attach usage to a chunk with empty choices
-- (the final usage chunk). Keys must match Go's TokenUsage json tags
-- (prompt/completion/total); the gateway re-emits standard *_tokens.
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,
}
if type(chunk.usage.prompt_tokens_details) == "table" and chunk.usage.prompt_tokens_details.cached_tokens ~= nil then
uses.prompt_tokens_details = { cached_tokens = chunk.usage.prompt_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
if not chunk.choices or #chunk.choices == 0 then
if uses ~= nil then
-- usage-only chunk is not a content/finish signal; the gateway
-- emits its own terminal stop chunk and merges this usage.
return json.encode({ usage = uses, done = false })
end
return ""
end
local delta = chunk.choices[1].delta or {}
local fr = chunk.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
unified.tool_calls = delta.tool_calls
end
return json.encode(unified)
-- 标准 OpenAI SSE → 统一信封。该实现此前在 groq/kimicode/mistral/github
-- 四个适配器里**逐字节重复**(各 2211B),已抽到共享前导。
return __prelude.openai_stream_chunk(raw_chunk)
end
-- 错误收敛:Mistral 用顶层 {message="...", type="..."}(非嵌套 error)

View File

@ -66,63 +66,9 @@ function adapter.transform_request(raw_body)
end
function adapter.transform_response(raw_body)
local ok, resp = pcall(json.decode, raw_body)
if not ok or resp == nil then return raw_body end
local unified = {
content = "",
finish_reason = "",
token_usage = { prompt = 0, completion = 0, total = 0 }
}
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
-- Cache passthrough: OpenAI v2 prompt_tokens_details.cached_tokens
-- and DeepSeek-legacy prompt_cache_hit/miss_tokens. dsh reads
-- prompt_tokens_details.cached_tokens (falls back to the legacy
-- standalone field), so both shapes reach clients.
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 ""
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, {
id = safe_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)
-- 非流式 OpenAI 响应 → 统一信封。此前在 openai/groq/kimicode/mistral/github
-- 五个适配器里**逐字节相同**(各 2206B,diff 为空),已抽到共享前导。
return __prelude.openai_response(raw_body)
end
function adapter.transform_stream_chunk(raw_chunk)
@ -191,44 +137,9 @@ end
-- 错误收敛:标准 OpenAI 信封 {error:{message,...}}
function adapter.transform_error(status, body)
-- Non-JSON body (nginx HTML error pages, plain text): extract a short
-- human-readable reason instead of letting the raw body reach the log.
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then
-- HTML error page: pull the <title> text (e.g. "413 Request Entity Too Large")
local title = string.match(body or "", "<title>(.-)</title>")
if title and title ~= "" then return title end
-- Bare text: first non-empty line, capped
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
-- Standard OpenAI envelope: {error:{message,...}} or {error:"..."}
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
-- Flat envelope used by many OpenAI-compatible gateways:
-- {"code":20012,"message":"Model does not exist..."}
-- {"code":"INVALID_API_KEY","message":"Invalid API key"}
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
-- Some gateways use {detail:"..."} (FastAPI style)
if type(resp.detail) == "string" and resp.detail ~= "" then
return resp.detail
end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -165,16 +165,9 @@ end
-- 错误收敛:sensenova 为 OpenAI 风格 {error:{message,...}};
-- 配额类错误单独点出便于客户端识别重置周期。
function adapter.transform_error(status, body)
local ok, resp = pcall(json.decode, body)
if not ok or type(resp) ~= "table" then return nil end
local e = resp.error
if type(e) ~= "table" then return nil end
if status == 429 and type(e.code) == "string"
and e.code == "insufficient_quota" then
return "workspace quota exhausted (resets periodically)"
end
if type(e.message) == "string" then return e.message end
return nil
-- OpenAI 风格错误体抽消息。此前在 deepseek/github/groq/kimicode/
-- openai/sensenova 六个适配器里逐字节相同(各 293B),已抽到共享前导。
return __prelude.openai_error(status, body)
end
return adapter

View File

@ -909,7 +909,7 @@ func TestBillingTracksCacheUsage(t *testing.T) {
}
// Only the first request reported a cache number.
if total["cache_reported_reqs"] != float64(1) {
t.Errorf("★ total.cache_reported_reqs = %v, want 1 — a source that never "+
t.Errorf("total.cache_reported_reqs = %v, want 1 — a source that never "+
"reports cache usage must be distinguishable from one reporting zero hits",
total["cache_reported_reqs"])
}
@ -986,11 +986,11 @@ func TestBillingCacheHitClampedInStats(t *testing.T) {
st := ps.State("billing").(map[string]interface{})
tot := st["total"].(map[string]interface{})
if tot["cache_hit_tokens"] != float64(100) {
t.Errorf("★ cache_hit_tokens = %v, want 100 (clamped to prompt_tokens)",
t.Errorf("cache_hit_tokens = %v, want 100 (clamped to prompt_tokens)",
tot["cache_hit_tokens"])
}
if tot["cache_fresh_tokens"] != float64(0) {
t.Errorf("★ cache_fresh_tokens = %v, want 0, never negative",
t.Errorf("cache_fresh_tokens = %v, want 0, never negative",
tot["cache_fresh_tokens"])
}
}

View File

@ -228,7 +228,7 @@ func TestBillingPageCoversItsData(t *testing.T) {
}
// TestPluginIconIsNotARawEmoji guards the sidebar icon. The billing plugin
// declared icon = "💰" and the WebUI drops that verbatim into the button, while
// declared icon = "" and the WebUI drops that verbatim into the button, while
// every native tab uses an inline SVG styled with `stroke: currentColor`. An
// emoji there renders at the wrong size and ignores the theme, so it does not
// match its neighbours — which is what the operator reported.
@ -335,7 +335,7 @@ func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
td := strings.Count(row, "</td>")
if th != td {
t.Errorf("★ header declares %d columns but row() emits %d cells — the "+
t.Errorf("header declares %d columns but row() emits %d cells — the "+
"table is misaligned from the first differing column on", th, td)
}
}

View File

@ -0,0 +1,164 @@
package lua
import (
"encoding/json"
"strings"
"testing"
)
// Gemini(Cloud Code)走**私有信封协议**,不是公开的 /v1beta/models/…。
// 参考 gemini.ts 点名三件必须保留:
// 1. 身份五头**逐字写死、不许随机**,且**不带** x-goog-api-key
// 2. 流式刻意**不带** Accept
// 3. 信封**逐层字母序**序列化(上游按字节比对)
//
// 此前我们的 gemini.lua 产的是公开的 contents/parts 裸体、且一个身份头都不发,
// 端点还指着公开路径 —— 凭据与协议都不匹配。
func TestChannelsGeminiPrivateProtocol(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", string(src)); err != nil {
t.Fatal(err)
}
names := map[string]bool{}
for _, a := range vm.ListAdapters() {
names[a.Name] = true
}
if !names["channels-gemini"] {
t.Fatalf("插件未装 channels-gemini;已装: %v", names)
}
// 内置 gemini(公开 API 用)不得被顶掉
if !names["gemini"] {
t.Error("内置 gemini 适配器被顶掉了")
}
if ep := vm.Endpoint("channels-gemini"); ep != "/v1internal:generateContent" {
t.Errorf("端点应为私有动词路径,实得 %q", ep)
}
if se := vm.StreamEndpoint("channels-gemini"); !strings.Contains(se, "streamGenerateContent") {
t.Errorf("流式端点不对: %q", se)
}
// 身份五头 + 不得带 google api key
hdrs, err := vm.BuildHeaders("channels-gemini", map[string]interface{}{"api_key": "tok"})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
for k, want := range map[string]string{
"User-Agent": "antigravity/4.3.0 (cmdc-pak)",
"x-client-name": "antigravity",
"x-client-version": "4.3.0",
"x-machine-id": "cmdc-pak",
"x-vscode-sessionid": "proxy",
} {
if hdrs[k] != want {
t.Errorf("身份头 %s 应为 %q,实得 %q", k, want, hdrs[k])
}
}
for _, bad := range []string{"x-goog-api-key", "x-goog-api-client", "Accept"} {
if _, ok := hdrs[bad]; ok {
t.Errorf("**不得**发 %s 头(参考点名)", bad)
}
}
// 信封:model/project/request/requestId/userAgent 五件 + request 内层
in := `{"model":"gemini-3.1-flash","messages":[` +
`{"role":"system","content":"sys"},` +
`{"role":"user","content":"hello"}],` +
`"max_tokens":100,"stream":true}`
out, err := vm.Transform("channels-gemini", "transform_request", in)
if err != nil {
t.Fatalf("Transform: %v", err)
}
t.Logf("信封: %s", out)
var env map[string]interface{}
if err := json.Unmarshal([]byte(out), &env); err != nil {
t.Fatalf("信封不是合法 JSON: %v\n%s", err, out)
}
for _, k := range []string{"model", "project", "request", "requestId", "userAgent"} {
if _, ok := env[k]; !ok {
t.Errorf("信封缺顶层字段 %s(私有端点要信封,不是裸 contents)", k)
}
}
if env["userAgent"] != "antigravity" {
t.Errorf("userAgent 应为 antigravity,实得 %v", env["userAgent"])
}
req, _ := env["request"].(map[string]interface{})
if req == nil {
t.Fatal("request 不是对象")
}
if _, ok := req["contents"]; !ok {
t.Error("request 缺 contents")
}
if _, ok := req["systemInstruction"]; !ok {
t.Error("system 消息应进 systemInstruction")
}
if _, ok := req["sessionId"]; !ok {
t.Error("request 缺 sessionId")
}
// ③ 字母序:顶层键与信封内层都必须有序(上游按字节比对)
if !isSortedJSONObject(out) {
t.Errorf("信封未按键字母序序列化(参考 marshalAlphabetical):\n%s", out)
}
// ④ 响应必须**剥一层**信封({response:{candidates}})—— 不剥则拿不到
// candidates,表现为"模型没回话"
respIn := `{"response":{"candidates":[{"content":{"parts":[{"text":"hi"}]},` +
`"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,` +
`"candidatesTokenCount":2,"totalTokenCount":5}}}`
rout, err := vm.Transform("channels-gemini", "transform_response", respIn)
if err != nil {
t.Fatalf("transform_response: %v", err)
}
var ru map[string]interface{}
if err := json.Unmarshal([]byte(rout), &ru); err != nil {
t.Fatalf("响应不是合法 JSON: %v\n%s", err, rout)
}
if ru["content"] != "hi" {
t.Errorf("响应未剥信封(content 应来自 response.candidates[0]):%s", rout)
}
if ru["finish_reason"] != "stop" {
t.Errorf("finishReason STOP 应映射为 stop,实得 %v", ru["finish_reason"])
}
tu, _ := ru["token_usage"].(map[string]interface{})
if tu == nil || tu["prompt"] != float64(3) {
t.Errorf("usageMetadata 未映射到 token_usage:%v", ru["token_usage"])
}
}
// isSortedJSONObject 检查 JSON 对象键是否字母序(只查顶层,够用)。
func isSortedJSONObject(s string) bool {
s = strings.TrimSpace(s)
if !strings.HasPrefix(s, "{") {
return false
}
dec := json.NewDecoder(strings.NewReader(s))
tok, err := dec.Token()
if err != nil {
return false
}
if d, ok := tok.(json.Delim); !ok || d != '{' {
return false
}
keys := []string{}
for dec.More() {
k, err := dec.Token()
if err != nil {
return false
}
keys = append(keys, k.(string))
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
return false
}
}
for i := 1; i < len(keys); i++ {
if keys[i-1] > keys[i] {
return false
}
}
return true
}

View File

@ -0,0 +1,102 @@
package lua
import (
"encoding/json"
"strings"
"testing"
)
// TRAE 不是 OpenAI 兼容:参考 trae.ts:30 明确「载荷必须转换 —— 映射为 SOLO
// 格式(function、config_name 等字段),不能透传」。此前我们把 trae 声明成
// adapter=openai 直发,端点对了、载荷全错。
//
// 本测试走**真实路径**:装插件(插件自己装 channels-trae 适配器)→ 用
// vm.Transform 调那个**已安装**的适配器。这比在插件 VM 里 load 一份副本更
// 可信:副本拿不到内核注入的共享前导,且无法发现"装了但没生效"。
func TestChannelsTraeSoloAdapter(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", string(src)); err != nil {
t.Fatal(err)
}
// 插件应在加载时自装 TRAE SOLO 适配器(名字 channels-trae,避开内置 trae)
names := map[string]bool{}
for _, a := range vm.ListAdapters() {
names[a.Name] = true
}
if !names["channels-trae"] {
t.Fatalf("插件未装 channels-trae 适配器;已装: %v", names)
}
if owner := ps.AdapterOwnerOf("channels-trae"); owner != "channels" {
t.Errorf("归属应为 channels,实得 %q", owner)
}
// 内置 trae(供运维的 trae-local-api 反代用)必须**不被顶掉**
if !names["trae"] {
t.Error("内置 trae 适配器被顶掉了(它仍服务运维自建的本地反代)")
}
in := `{"model":"glm-5.2","messages":[` +
`{"role":"system","content":"you are helpful"},` +
`{"role":"user","content":"hi"},` +
`{"role":"assistant","content":"ok","tool_calls":[` +
`{"id":"c1","type":"function","function":{"name":"f","arguments":"{}"}},` +
`{"id":"c2","type":"function","function":{"name":"","arguments":"{}"}}]}` +
`],"tools":[{"type":"function","function":{"name":"f","description":"d",` +
`"parameters":{"type":"object","properties":{"a":{"type":"string"}}}}}],` +
`"tool_choice":{"type":"function","function":{"name":"f"}},` +
`"stream":false,"extra_body":{"x":1}}`
out, err := vm.Transform("channels-trae", "transform_request", in)
if err != nil {
t.Fatalf("Transform: %v", err)
}
t.Logf("SOLO 载荷: %s", out)
for _, want := range []struct{ desc, needle string }{
{"规则2 stream 必须强制 true(非流式由服务端聚合)", `"stream":true`},
{"规则3 model → config_name + model 双字段", `"config_name":"glm-5.2"`},
{"规则4 function 通道(缺省 solo_work_lite)", `"function":"solo_work_lite"`},
{"规则1 content 字符串 → parts 数组", `"text":"hi"`},
{"规则6 tool_calls 的 function → function_call", `"function_call"`},
{"规则7 parameters 必须是 JSON 字符串(SOLO 要求)", `{\"properties\"`},
{"规则5 tool_choice 为 function 时归一成 name 字符串", `"tool_choice":"f"`},
} {
if !strings.Contains(out, want.needle) {
t.Errorf("%s\n 缺 %s\n 实际: %s", want.desc, want.needle, out)
}
}
// 反向断言
if strings.Contains(out, `"extra_body"`) {
t.Errorf("内核私有字段 extra_body 未剥离:%s", out)
}
if strings.Contains(out, `"id":"c2"`) {
t.Errorf("无 name 的 tool_call 应被剔除(孤儿会让整条会话被拒):%s", out)
}
// 必须是合法 JSON,且 model/config_name 同值、stream 为 true
var parsed map[string]interface{}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
t.Fatalf("转换结果不是合法 JSON: %v\n%s", err, out)
}
if parsed["model"] != "glm-5.2" || parsed["config_name"] != "glm-5.2" {
t.Errorf("model/config_name 应同为 glm-5.2:%v / %v", parsed["model"], parsed["config_name"])
}
if parsed["stream"] != true {
t.Errorf("stream 应为 true,实得 %v", parsed["stream"])
}
// tool_choice=none 时必须同时删掉 tools 与 tool_choice 本身
noneIn := `{"model":"glm-5.2","messages":[{"role":"user","content":"x"}],` +
`"tools":[{"type":"function","function":{"name":"f","parameters":{"type":"object"}}}],` +
`"tool_choice":"none"}`
noneOut, err := vm.Transform("channels-trae", "transform_request", noneIn)
if err != nil {
t.Fatalf("Transform(none): %v", err)
}
if strings.Contains(noneOut, `"tools"`) {
t.Errorf("tool_choice=none 时 tools 必须被删:%s", noneOut)
}
if strings.Contains(noneOut, `"tool_choice"`) {
t.Errorf("tool_choice=none 时该字段本身也应被删:%s", noneOut)
}
}

View File

@ -90,7 +90,7 @@ func TestFastPathIsActuallyUsed(t *testing.T) {
"ts": float64(1700000000),
}
if _, ok := fastToPlain(realistic); !ok {
t.Errorf("★ the realistic request payload does NOT take the fast path — " +
t.Errorf("the realistic request payload does NOT take the fast path — " +
"the JSON round-trip is still on the hot path for real traffic")
}
}

View File

@ -79,7 +79,7 @@ return p
psOut := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})
if singleOut["who"] != "from-aaa" {
t.Errorf("★ single-plugin path dropped the return value: %v", singleOut)
t.Errorf("single-plugin path dropped the return value: %v", singleOut)
}
// Two plugins both return `who`; the merge order decides the winner, and it
// must be deterministic (load order), not scheduler-dependent.
@ -120,7 +120,7 @@ return p
for i := 0; i < 60; i++ {
got := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m"})["winner"]
if got != first {
t.Fatalf("★ winner changed between runs: %q then %q — merge depends on goroutine "+
t.Fatalf("winner changed between runs: %q then %q — merge depends on goroutine "+
"scheduling, so the same configuration behaves differently run to run", first, got)
}
}
@ -164,7 +164,7 @@ return p
t.Fatal("healthy plugin has no state")
}
if n := st["n"]; n != float64(1) {
t.Errorf("★ healthy plugin did not run alongside the throwing one: n = %v", n)
t.Errorf("healthy plugin did not run alongside the throwing one: n = %v", n)
}
he := ps.HookErrors()
if len(he) == 0 {

View File

@ -0,0 +1,74 @@
package lua
import (
"encoding/json"
"os"
"path/filepath"
"testing"
)
// 持久化的出站主机授权必须在**重启后自动生效**。
//
// 实测缺陷(生产):LoadKernelPermissions 把已批准的 host 装进 ps.extraHosts,
// 但注释明说"折叠由调用方负责" —— 启动路径上没人负责。SetOutboundHosts 早已
// 跑过(core.New 里、LoadDir 之前),于是 allowlist 只有 source 主机,
// 插件声明的整套 host(zcode.z.ai / copilot.tencent.com …)在重启后全被拒:
//
// host "zcode.z.ai" is not an allowed outbound destination
//
// 症状是经典的"重启丢掉运维的决定"——运维一打开插件页重新保存就恢复。
// 它还打坏了定时任务:refresh_models 在启动后 5s 触发,正好落在那个窗口里,
// 于是每个平台的刷新都报"授权缺失"。
func TestPersistedHostGrantsSurviveRestart(t *testing.T) {
dir := t.TempDir()
plugDir := filepath.Join(dir, "plugins")
if err := os.MkdirAll(plugDir, 0o755); err != nil {
t.Fatal(err)
}
// 造一份持久化授权(模拟运维此前在 WebUI 批准过)
perms := map[string]interface{}{
"channels": map[string]interface{}{
"actions": []string{"add_source"},
"hosts": []string{"zcode.z.ai", "copilot.tencent.com"},
},
}
b, _ := json.Marshal(perms)
if err := os.WriteFile(filepath.Join(plugDir, ".kernel-perms.json"), b, 0o600); err != nil {
t.Fatal(err)
}
// 模拟启动:先 SetOutboundHosts(源主机),再 LoadDir(读授权)
vm := NewVM(filepath.Join(dir, "adapters"))
if err := vm.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(vm.Stop)
ps := NewPlugins(vm, plugDir)
ps.SetOutboundHosts([]string{"api.example.com"})
// LoadDir 会读授权文件;没有插件文件也没关系,关键是它必须折叠授权。
if err := ps.LoadDir(); err != nil {
t.Fatalf("LoadDir: %v", err)
}
al := ps.outboundAllowlist()
if al == nil {
t.Fatal("无 allowlist")
}
// ① 源主机仍可用
if !al.allows("api.example.com", "443") {
t.Error("source 主机被丢了")
}
// ② 已批准的 extra host 必须生效(这就是本判据的核心)
for _, h := range []string{"zcode.z.ai", "copilot.tencent.com"} {
if !al.allows(h, "443") {
t.Errorf("重启后已批准的 host %q 被拒 —— "+
"LoadKernelPermissions 装了 extraHosts 但没人折叠进 allowlist", h)
}
}
// ③ 没批准过的仍然不许
if al.allows("evil.example.com", "443") {
t.Error("未批准的 host 不该被放行")
}
}

View File

@ -91,7 +91,7 @@ func TestStateSurvivesRestart(t *testing.T) {
loadCounter(t, ps2)
if got := stateN(t, ps2); got != 25 {
t.Fatalf("★ total after restart = %v, want 25 — this is the 'restart loses the books' bug", got)
t.Fatalf("total after restart = %v, want 25 — this is the 'restart loses the books' bug", got)
}
}
@ -160,7 +160,7 @@ func TestPricesOnlyUpdatePersists(t *testing.T) {
t.Fatal(err)
}
if saved.Prices["rate"] != float64(42) {
t.Errorf("★ new price not persisted: %v — a restart would revert to the old price", saved.Prices)
t.Errorf("new price not persisted: %v — a restart would revert to the old price", saved.Prices)
}
if saved.State["n"] != float64(5) {
t.Errorf("state was clobbered by a price change: %v", saved.State["n"])
@ -189,7 +189,7 @@ return plugin
}
out := ps.Fire(StageRequestEnd, map[string]interface{}{"model": "m", "source": "s"})
if out["cost_usd"] != 1.25 {
t.Errorf("★ hook return value lost: payload = %v", out)
t.Errorf("hook return value lost: payload = %v", out)
}
if out["verdict"] != "billed" {
t.Errorf("merged field lost: %v", out)
@ -269,7 +269,7 @@ func TestCloseFlushesTail(t *testing.T) {
b, err := os.ReadFile(ps.stateFile("counter"))
if err != nil {
t.Fatalf("★ Close() did not flush: %v — a shutdown loses the tail", err)
t.Fatalf("Close() did not flush: %v — a shutdown loses the tail", err)
}
var saved struct {
State map[string]interface{} `json:"state"`
@ -325,7 +325,7 @@ return plugin
b, err := os.ReadFile(ps.stateFile("ondemand"))
if err != nil {
t.Fatalf("★ an exported call did not mark the plugin dirty: %v — a plugin "+
t.Fatalf("an exported call did not mark the plugin dirty: %v — a plugin "+
"with no hooks can never persist its work", err)
}
var saved struct {

View File

@ -0,0 +1,224 @@
package lua
import (
"fmt"
"strings"
"testing"
)
// 本文件锁死"插件装的适配器能做到哪些事"——三条能力,缺任何一条,
// 为协议转换类平台(trae SOLO / gemini 信封 / codearts AK/SK 签名)
// 装专用适配器这条路就是死的。都是**实测**得出的机制事实,不是推断。
//
// 背景:参考插件每个平台一个继承宿主 LlmAdapter 的真类,自己实现推理协议;
// 我们的内核是 proxy,平台差异此前被当成 adapter 名差异(全塞 openai)。
// 内核其实早已实现 __llmsproxy_adapters.install,只是 channels 插件没用过。
// ── 能力①:适配器能拿到凭据(AK/SK 签名必需),但拿不到保险库 ──
//
// 安全边界:__llmsproxy_secrets 只注入插件 VM(plugin_bridges.go 开篇明说
// "适配器共用 setupGlobals 而不该拿到这些能力")。适配器要凭据只能走
// build_headers 的 meta.api_key —— 那是**内核按源注入**的,范围受控。
const credentialProbeAdapter = `
local adapter = {}
adapter.name = "credprobe"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.build_headers(meta)
local ak = tostring(meta.api_key or "")
return {
["X-Probe-Has-Key"] = (ak ~= "") and "yes" or "no",
["X-Probe-Key-Len"] = tostring(#ak),
["X-Probe-Secrets-Global"] = (__llmsproxy_secrets ~= nil) and "yes" or "no",
["X-Probe-Has-Url"] = tostring(meta.url or ""),
["X-Probe-Method"] = tostring(meta.method or ""),
["X-Probe-Body-Len"] = tostring(#(meta.body or "")),
}
end
return adapter
`
const credentialProbePlugin = `
local plugin = { name = "capcred", version = "1.0" }
plugin.exports = { "install" }
function plugin.install()
return { name = (__llmsproxy_adapters.install("credprobe", [==[` + credentialProbeAdapter + `]==])) }
end
return plugin
`
func TestAdapterCapabilityCredentialAccess(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("capcred", credentialProbePlugin); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("capcred", "install", nil); err != nil {
t.Fatal(err)
}
// 完整凭据 JSON(codearts 场景:AK/SK/ST 都在里面)
cred := `{"access_key_id":"AK","secret_access_key":"SK","security_token":"ST"}`
hdrs, err := vm.BuildHeaders("credprobe", map[string]interface{}{
"url": "https://sts.example/v1/oauth2/tokens", "method": "POST",
"body": "grant_type=x", "api_key": cred,
})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
if hdrs["X-Probe-Has-Key"] != "yes" {
t.Error("适配器必须能通过 meta.api_key 拿到凭据(AK/SK 签名的前提)")
}
if hdrs["X-Probe-Key-Len"] != fmt.Sprint(len(cred)) {
t.Errorf("api_key 长度 %s ≠ %d(凭据被截断?)", hdrs["X-Probe-Key-Len"], len(cred))
}
// 安全边界:适配器**不得**能读保险库
if hdrs["X-Probe-Secrets-Global"] != "no" {
t.Error("安全回归:适配器拿到了 __llmsproxy_secrets —— " +
"该全局只应注入插件 VM")
}
// build_headers 必须能拿到 url/method/body(签名要用的签名字符串原材料)
if !strings.Contains(hdrs["X-Probe-Has-Url"], "sts.example") {
t.Error("build_headers 拿不到 url(无法构造签名)")
}
if hdrs["X-Probe-Method"] != "POST" {
t.Errorf("build_headers 拿不到 method:%q", hdrs["X-Probe-Method"])
}
if hdrs["X-Probe-Body-Len"] != "12" {
t.Errorf("build_headers 拿不到 body:len=%s", hdrs["X-Probe-Body-Len"])
}
}
// ── 能力②:流式转换跨 chunk 保持状态 ──
//
// 为什么必须测:Trae 的 SOLO 转换要跨帧累积 tool_calls,gemini 要跨帧剥
// 信封。机制上 Transform 走池化 acquire(LIFO 复用),单个流是串行调用,
// 所以**应当**拿回同一个 worker —— 但那是推断,这里实测。
const streamStateAdapter = `
local adapter = {}
adapter.name = "streambuf"
adapter.version = "1"
adapter.endpoint = "/x"
local calls = 0
local acc = {}
function adapter.transform_stream_chunk(raw)
calls = calls + 1
acc[#acc + 1] = raw
return '{"calls":' .. calls .. ',"acc":' .. #acc .. ',"vm":"' .. tostring(adapter) .. '"}'
end
return adapter
`
const streamStatePlugin = `
local plugin = { name = "capstream", version = "1.0" }
plugin.exports = { "install" }
function plugin.install()
return { name = (__llmsproxy_adapters.install("streambuf", [==[` + streamStateAdapter + `]==])) }
end
return plugin
`
func TestAdapterCapabilityStreamStateAcrossChunks(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("capstream", streamStatePlugin); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("capstream", "install", nil); err != nil {
t.Fatal(err)
}
var last, firstVM string
for i := 1; i <= 5; i++ {
out, err := vm.Transform("streambuf", "transform_stream_chunk",
`{"chunk":`+fmt.Sprint(i)+`}`)
if err != nil {
t.Fatalf("chunk %d: %v", i, err)
}
last = out
if i == 1 {
firstVM = out[strings.Index(out, `"vm"`):]
}
}
if !strings.Contains(last, `"calls":5`) {
t.Fatalf("跨 chunk 状态未保持:末次 %s(期望 calls:5;若为 1 则每个 chunk "+
"都拿到全新 Lua 状态,协议转换类适配器不可行)", last)
}
if !strings.Contains(last, `"acc":5`) {
t.Fatalf("累积数组未跨 chunk 保持:%s", last)
}
// 同一个 Lua 状态(表地址恒定)—— 比计数更直接的证据
if got := last[strings.Index(last, `"vm"`):]; got != firstVM {
t.Errorf("chunk 间 Lua 状态被换了:首 %s 末 %s", firstVM, got)
}
}
// ── 能力③:适配器能自带加密原语(签名/摘要)──
//
// 内核在 setupGlobals 注册 sha256_hex / hmac_sha256_hex,适配器与插件共用,
// 所以 AK/SK 签名(SDK-HMAC-SHA256)不需要外部进程。
const cryptoProbeAdapter = `
local adapter = {}
adapter.name = "cryptoprobe"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.probe()
return {
has_sha256 = (sha256_hex ~= nil) and "yes" or "no",
has_hmac = (hmac_sha256_hex ~= nil) and "yes" or "no",
-- 固定向量:sha256("abc")
sha256_abc = sha256_hex and sha256_hex("abc") or "",
hmac_ok = hmac_sha256_hex and hmac_sha256_hex("k", "v") or "",
}
end
return adapter
`
const cryptoProbePlugin = `
local plugin = { name = "capcrypto", version = "1.0" }
plugin.exports = { "install", "probe" }
function plugin.install()
return { name = (__llmsproxy_adapters.install("cryptoprobe", [==[` + cryptoProbeAdapter + `]==])) }
end
-- 探针:直接要求内核在该适配器上下文里跑一个函数是不可能的(Transform 只认
-- 那几个钩子),故用 transform_request 把 probe 结果带出来。
return plugin
`
func TestAdapterCapabilityCryptoPrimitives(t *testing.T) {
vm, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("capcrypto", cryptoProbePlugin); err != nil {
t.Fatal(err)
}
if _, err := ps.Call("capcrypto", "install", nil); err != nil {
t.Fatal(err)
}
if !vm.HasFn("cryptoprobe", "probe") {
t.Skip("适配器未导出 probe(本断言不依赖它)")
}
// 直接用 Transform 调 probe 不行(它只认 transform_* 钩子),
// 改为验证内核确实注册了两个原语:拿一个用它们的适配器做端到端。
h, err := vm.BuildHeaders("cryptoprobe", map[string]interface{}{"api_key": "k"})
if err != nil {
t.Fatalf("BuildHeaders: %v", err)
}
_ = h
// 真正的断言放在下面:装一个用 sha256_hex 的适配器并跑它的钩子
if err := vm.LoadAdapterSource("hashproof", `
local adapter = {}
adapter.name = "hashproof"
adapter.version = "1"
adapter.endpoint = "/x"
function adapter.transform_request(raw)
-- sha256("abc") 的标准值(用内核原语算;写死的是期望值)
return '{"got":"' .. sha256_hex("abc") .. '"}'
end
return adapter
`); err != nil {
t.Fatal(err)
}
out, err := vm.Transform("hashproof", "transform_request", "{}")
if err != nil {
t.Fatalf("transform_request: %v", err)
}
const wantSHA256abc = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
if !strings.Contains(out, wantSHA256abc) {
t.Fatalf("内核 sha256_hex 原语在适配器里不可用或不正确:%s", out)
}
}

View File

@ -24,6 +24,27 @@ import (
// secretGlobal 是凭据保险库的 Lua 名字。
const secretGlobal = "__llmsproxy_secrets"
// pluginDirGlobal carries the plugin's OWN directory as ground truth.
//
// Why it must come from the kernel (live defect): the channels plugin decides
// where to keep its state by reading /etc/llmsproxy/config.yaml when
// LLMSPROXY_PLUGIN_DIR is unset. Tests load the real plugin but never set that
// variable, so they read AND WROTE the production store — one full
// `go test ./internal/lua` replaced the production account pool with the test's
// empty one (13 accounts → 0; the credentials stayed in the vault, so the
// symptom was "accounts vanished"). The kernel already knows the true directory
// (ps.dir), so handing it over removes the guess entirely: a plugin no longer
// has to infer its own location from a config file it might not own.
const pluginDirGlobal = "__llmsproxy_plugin_dir"
// registerPluginDirBridge installs the plugin's own directory as a plain string
// global. Deliberately a string, not a table: there is nothing to call, and a
// bare value cannot be shadowed by a later SetField on a shared table.
func registerPluginDirBridge(L *golua.State, dir string) {
L.PushString(dir)
L.SetGlobal(pluginDirGlobal)
}
// kernelGlobal 是内核动作面的 Lua 名字。
const kernelGlobal = "__llmsproxy_kernel"

View File

@ -55,6 +55,18 @@ type ScheduledTask struct {
// Jitter, in seconds, spreads the first fire randomly so many plugins (or
// many replicas) do not all hit the same upstream at :00.
Jitter int `json:"jitter,omitempty"`
// FirstDueNow makes the FIRST run happen shortly after boot instead of after
// a full period.
//
// Why it exists (measured defect, borrowed from the reference implementation's
// post-mortem): with interval-only scheduling a restart leaves short-lived
// credentials expired for up to a whole period. The reference's providers
// have cline at 1h, codearts at ~2h and raccoon at 3h, so a 30-minute
// refresh interval left them showing "已过期" and their quota rows returning
// 401 after every host restart. A startup catch-up round fixes that — but it
// must be opt-in, because a task that is genuinely periodic (a daily
// check-in, say) should NOT fire again just because the host restarted.
FirstDueNow bool `json:"first_due_now,omitempty"`
// Timeout is parsed for forward compatibility but is NOT enforced: a running
// Lua call cannot be interrupted in this LuaJIT build (see the capability
// note on checkWedged). Overrun is handled by disarming the plugin at the
@ -106,6 +118,7 @@ func parseSchedule(L *golua.State) scheduleSpec {
t.At = readString(L, -1, "at")
t.Jitter = readNumberInt(L, -1, "jitter")
t.Timeout = readNumberInt(L, -1, "timeout")
t.FirstDueNow = readBoolField(L, -1, "first_due_now")
if t.Fn != "" && (t.Every > 0 || t.At != "") {
spec.tasks = append(spec.tasks, t)
}
@ -195,6 +208,12 @@ func (ps *Plugins) startRuntime(p *Plugin, spec scheduleSpec) *PluginRuntime {
// plugins do not all wake at process start.
func (rt *PluginRuntime) firstDue(t ScheduledTask) time.Time {
now := time.Now()
// A catch-up run: fire shortly after boot so a restart does not leave
// short-lived credentials expired for a whole period (see FirstDueNow).
// The short delay lets the rest of the plugin finish booting first.
if t.FirstDueNow {
return now.Add(catchUpDelay)
}
if t.At != "" {
return nextDailyOccurrence(t.At, now)
}
@ -319,6 +338,11 @@ func (rt *PluginRuntime) runDue() {
}
}
// catchUpDelay is how long a FirstDueNow task waits after boot. Long enough for
// the plugin's own boot work to finish, short enough that a restart heals
// expired credentials promptly rather than after a full period.
const catchUpDelay = 5 * time.Second
// nextAfter computes the following due time for a task that just fired.
func (rt *PluginRuntime) nextAfter(t ScheduledTask, now time.Time) time.Time {
if t.At != "" {
@ -489,6 +513,17 @@ func randInt63n(n int64) int64 {
// readNumberInt reads a numeric field as an int. Missing/zero yields 0, which
// every caller treats as "not set" (a task with every<=0 and no at is disabled).
// readBoolField reads a boolean field, treating only a real Lua boolean as set.
// A missing field (nil) means false — the caller's declared default.
func readBoolField(L *golua.State, idx int, field string) bool {
L.GetField(idx, field)
defer L.Pop(1)
if L.Type(-1) != golua.LUA_TBOOLEAN {
return false
}
return L.ToBoolean(-1)
}
func readNumberInt(L *golua.State, idx int, field string) int {
L.GetField(idx, field)
defer L.Pop(1)

View File

@ -2,6 +2,7 @@ package lua
import (
"fmt"
"os"
"testing"
"time"
)
@ -30,8 +31,23 @@ func runtimePlugins(t *testing.T) (*VM, *Plugins, string) {
}
t.Cleanup(vm.Stop)
pdir := fmt.Sprintf("%s/plugins", t.TempDir())
// ⚠️ **必须**设 LLMSPROXY_PLUGIN_DIR。channels 插件用 storeDir() 决定自己的
// 存储位置,而 storeDir 在环境变量缺失时会去读 /etc/llmsproxy/config.yaml ——
// 那是**生产路径**。曾经因此把生产账号池覆盖成测试的空 pool
// (真实事故:一次全量 internal/lua 后 /etc/llmsproxy/plugins/
// .channels-store.json 里 13 个账号变成 0 个,凭据却还留在保险库)。
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
t.Setenv("LLMSPROXY_PLUGIN_DIR", pdir)
}
ps := NewPlugins(vm, pdir)
ps.SetOutboundHosts([]string{"127.0.0.1"})
// ⚠️ 关掉状态持久化。插件声明了 schedule 时内核会自动起调度器
// (plugins.go: p.Runtime = ps.startRuntime(...)),而真 channels 的
// refresh_models 会在 5s 后(first_due_now)跑一轮并 logEvent → markDirty。
// 那是**后台 goroutine**,比 t.Setenv 的生命周期长:测试结束后它仍可能落盘,
// 此时环境变量已还原,storeDir() 就兜底到生产路径。
// 实测:只跑 internal/lua 全会话,生产 store 的 log 会多出调度器条目。
ps.DisableStatePersistence()
return vm, ps, pdir
}

View File

@ -47,6 +47,6 @@ return plugin`
time.Sleep(500 * time.Millisecond)
if err := exec.Command("kill", "-0", pid).Run(); err == nil {
exec.Command("kill", "-9", pid).Run()
t.Fatal("★ unload 后进程仍存活 —— on_unload 未被调用")
t.Fatal("unload 后进程仍存活 —— on_unload 未被调用")
}
}

View File

@ -634,6 +634,24 @@ func (ps *Plugins) LoadDir() error {
}
}
ps.rebuild()
// Persisted host grants must be folded into the allowlist HERE, not only in
// the WebUI grant path.
//
// Why (live defect): LoadKernelPermissions above loads `jp.Hosts` into
// ps.extraHosts but deliberately does NOT fold it — its comment says "the
// caller owns that fold". On the boot path nobody did. SetOutboundHosts had
// already run (core.New, before LoadDir), so the allowlist held the source
// hosts and nothing else, and every approved extra host (the plugin's whole
// declared set: zcode.z.ai, copilot.tencent.com, …) was refused after a
// restart with "host ... is not an allowed outbound destination".
//
// Symptom is the classic "restart loses the operator's decision": it clears
// the moment an operator opens the plugin page and re-saves the grants. It
// also broke the scheduled work — refresh_models fires 5s after boot, inside
// that window, and reported the grant as missing for every platform.
ps.mu.Lock()
ps.rebuildOutboundAllowlistLocked()
ps.mu.Unlock()
return nil
}
@ -686,6 +704,8 @@ func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*wor
// the kernel.
registerSecretBridge(L, ps, name)
registerKernelBridge(L, ps, name)
// The plugin's own directory, as ground truth (see pluginDirGlobal).
registerPluginDirBridge(L, ps.dir)
}
if err := L.DoString(code); err != nil {
L.Close()

View File

@ -260,7 +260,7 @@ end
-- 语义:命中任一 window ⇒ 乘以 multiplier。hours 用 {起,止} 闭区间,跨零点
-- 用 {{22,24}} 表示 22:00-24:00(24 是"当天最后一刻")。
--
-- ★ 为什么用 os.date 的 ! 前缀取 UTC:provider 的费率表按 UTC 标注,而网关
-- 关键:为什么用 os.date 的 ! 前缀取 UTC:provider 的费率表按 UTC 标注,而网关
-- 跑在本地时区(这台机是 Asia/Hong_Kong)。混用本地小时会让峰谷整体偏移 8
-- 小时,白天算成夜间——比不做峰谷还糟。
local function inPeakWindow(ev)

File diff suppressed because it is too large Load Diff

View File

@ -1177,7 +1177,7 @@ PLUGIN.ui = {
'" style="width:'+Math.min(100,p)+'%"></div></div>'+
'<div class="qa-w-d">$'+used.toFixed(2)+' / $'+cap.toFixed(0)+'</div>';
if(r.cap_mismatch){
h+='<div class="qa-warnl" title="已用+余额 与套餐额度不符,比例仅供参考">⚠ 额度口径可能有变</div>';
h+='<div class="qa-warnl" title="已用+余额 与套餐额度不符,比例仅供参考">注意 额度口径可能有变</div>';
}
return h+"</div>";
}

View File

@ -20,7 +20,7 @@
-- 取自己的 /api/stats —— 它已按调用 key 隔离(实测:user key 231 次 = 自己的,
-- admin 33847 次 = 全量)。
--
-- ★ 必须传**掩码**,不能传完整 key。
-- 关键:必须传**掩码**,不能传完整 key。
-- /api/stats 的统计 map 以 keyID(req.Key)(即 "***f9a9ed")为键 —— by_key
-- 返回的就是它,面板传的也是它。而 admin 身份下 ?key= 接受任意值:传完整 key
-- 会查不到,拿到零值。实测:

View File

@ -220,7 +220,7 @@ p.ui = {
page = {
page_id = "billing",
title = "Billing",
icon = "💰",
icon = "",
order = 50,
mount = "<div id=billing>hi</div><script>console.log('m')</script>",
},

View File

@ -0,0 +1,104 @@
package lua
import (
"testing"
"time"
)
// 「启动即跑一轮」判据。
//
// 为什么需要它(照参考的事后复盘):只用 interval 时,宿主重启后短寿命凭据
// (cline 1h / codearts ~2h / raccoon 3h)要等满一个周期才续期,期间一直显示
// 「已过期」、积分行 401。修复 = 声明 first_due_now 的任务在启动后短暂延迟内
// 先跑一轮。
//
// 但它**必须 opt-in**:真正周期性的任务(如每日签到)不该因为重启就重跑。
func TestScheduleFirstDueNow(t *testing.T) {
// ① 声明了 first_due_now 的任务:首轮应在 catchUpDelay 内,而不是等一个周期
now := time.Now()
catchUp := ScheduledTask{Fn: "f", Every: 7200, FirstDueNow: true}
rt := &PluginRuntime{}
d := rt.firstDue(catchUp)
if d.After(now.Add(catchUpDelay + time.Second)) {
t.Errorf("first_due_now 的任务首轮应在 %v 内,实得 %v 后",
catchUpDelay, d.Sub(now))
}
if d.Before(now) {
t.Errorf("首轮时间不该在过去:%v", d.Sub(now))
}
// ② 没声明的:必须等满一个周期(否则每日/每小时的语义会被破坏)
plain := ScheduledTask{Fn: "f", Every: 7200}
d2 := rt.firstDue(plain)
if d2.Sub(now) < 7000*time.Second {
t.Errorf("未声明 first_due_now 的任务不该提前跑:实得 %v", d2.Sub(now))
}
// ③ at 形式(每日固定时刻)不受标记影响 —— 它本来就该等到那个时刻
daily := ScheduledTask{Fn: "f", At: "08:00"}
d3 := rt.firstDue(daily)
if d3.Sub(now) > 25*time.Hour {
t.Errorf("每日任务的首次时间超出一天:%v", d3.Sub(now))
}
if d3.Sub(now) < catchUpDelay && !daily.FirstDueNow {
// 只有当 08:00 恰好在 catchUpDelay 内才可能,属正常
t.Logf("每日任务首轮临近(可能恰好在 08:00 附近):%v", d3.Sub(now))
}
// ④ 声明了 first_due_now 的每日任务:优先补跑(凭据不等时刻)
dailyCatchUp := ScheduledTask{Fn: "f", At: "08:00", FirstDueNow: true}
d4 := rt.firstDue(dailyCatchUp)
if d4.Sub(now) > catchUpDelay+time.Second {
t.Errorf("声明 first_due_now 时即便有 at 也应先补跑:%v", d4.Sub(now))
}
}
// 解析层判据:first_due_now 必须真的从插件源码读出来(写错键名会静默变成
// false —— 那就是"看起来配了、实际没补跑")。
func TestScheduleParsesFirstDueNow(t *testing.T) {
const src = `
local plugin = { name = "schedprobe", version = "1.0" }
plugin.exports = { "noop" }
function plugin.noop() end
function plugin.refresh_creds() end
function plugin.auto_claim() end
plugin.schedule = {
{ fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 },
{ fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 },
}
return plugin
`
_, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("schedprobe", src); err != nil {
t.Fatal(err)
}
ps.mu.RLock()
var sched []ScheduledTask
for _, p := range ps.plugins {
if p.Info.Name == "schedprobe" {
sched = p.Schedule
}
}
ps.mu.RUnlock()
if len(sched) != 2 {
t.Fatalf("应解析出 2 个任务,实得 %d: %+v", len(sched), sched)
}
byFn := map[string]ScheduledTask{}
for _, task := range sched {
byFn[task.Fn] = task
}
if task, ok := byFn["refresh_creds"]; !ok {
t.Fatal("refresh_creds 任务未解析")
} else if !task.FirstDueNow {
t.Error("first_due_now 未被解析为 true(键名写错会静默失效)")
} else if task.Every != 1800 {
t.Errorf("every = %d,应为 1800", task.Every)
}
if task, ok := byFn["auto_claim"]; !ok {
t.Fatal("auto_claim 任务未解析")
} else if task.FirstDueNow {
t.Error("auto_claim 未声明 first_due_now,应为 false(每日任务不该因重启重跑)")
} else if task.At != "08:00" {
t.Errorf("at = %q,应为 08:00", task.At)
}
}

View File

@ -98,7 +98,7 @@ func TestSecretVaultEncryptedAtRest(t *testing.T) {
t.Fatal(err)
}
if strings.Contains(string(raw), "SUPER-TOKEN-123") {
t.Fatalf("★ 保险库明文落盘! 文件内容含 token:\n%s", raw)
t.Fatalf("保险库明文落盘! 文件内容含 token:\n%s", raw)
}
if !strings.Contains(string(raw), testEncPrefix) {
t.Fatalf("落盘值应带 %s 前缀(统一密文形态):\n%s", testEncPrefix, raw)
@ -140,7 +140,7 @@ func TestSecretVaultPlaintextFallbackAndUpgrade(t *testing.T) {
}
raw, _ := os.ReadFile(filepath.Join(dir, ".secrets", "channels.json"))
if strings.Contains(string(raw), "OLD-PLAIN") {
t.Fatalf("★ 升级写后旧明文值仍在盘上:\n%s", raw)
t.Fatalf("升级写后旧明文值仍在盘上:\n%s", raw)
}
var m map[string]string
if err := json.Unmarshal(raw, &m); err != nil {
@ -169,7 +169,7 @@ func TestSecretVaultWrongMasterKey(t *testing.T) {
// 换一把钥匙(模拟 master.key 丢失重建)
ps.SetSecretBox(failBox{})
if _, _, err := ps.GetSecret("channels", "k"); err == nil {
t.Fatal("★ 换 master key 后读库未报错 —— 会静默吞凭据")
t.Fatal("换 master key 后读库未报错 —— 会静默吞凭据")
}
}
@ -205,7 +205,7 @@ func TestSecretVaultConcurrentPlugins(t *testing.T) {
// 跨插件必须不可见
other := "plug" + string(rune('A'+(p+1)%plugins))
if _, ok, _ := ps.GetSecret(other, key); ok {
t.Errorf("★ 凭据串台: %s 的键在 %s 可见", name, other)
t.Errorf("凭据串台: %s 的键在 %s 可见", name, other)
return
}
if g == 1 && i%10 == 9 {

View File

@ -0,0 +1,61 @@
package lua
import (
"os"
"strings"
"testing"
)
// **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。
//
// 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底:
// 环境变量 → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** →
// 硬编码 /etc/llmsproxy/plugins。测试台没设环境变量时,后两级直接命中**生产**:
//
// 测试插件目录 = /tmp/TestXXX/002/plugins
// storeDir 结果 = /etc/llmsproxy/plugins ← 实测
//
// 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量
// `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool:
// /etc/llmsproxy/plugins/.channels-store.json 从 1730 变 1986 字节,
// 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。
//
// 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产
// .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。
func TestStoreDirIsolatedInTests(t *testing.T) {
_, ps, pdir := runtimePlugins(t)
src, err := bundledAdapters.ReadFile("plugins/channels.lua")
if err != nil {
t.Fatal(err)
}
if err := ps.LoadSource("channels", string(src)); err != nil {
t.Fatal(err)
}
out, err := ps.Call("channels", "__store_dir_probe", nil)
if err != nil {
t.Fatalf("探针: %v", err)
}
w, _ := out.(map[string]interface{})
j, _ := w["json"].(map[string]interface{})
if j == nil {
t.Fatalf("探针无 json: %#v", out)
}
got, _ := j["store_dir"].(string)
t.Logf("测试插件目录 = %s", pdir)
t.Logf("storeDir 结果 = %s", got)
// ① 绝不能是生产目录
if got == "/etc/llmsproxy/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") {
t.Fatalf("storeDir 指向了**生产**目录 %q —— 测试会覆盖生产账号池\n"+
"(真实事故:13 个账号被覆盖成 0 个)。修法:runtimePlugins 设\n"+
"LLMSPROXY_PLUGIN_DIR 到 t.TempDir()", got)
}
// ② 必须落在测试的临时目录里
if !strings.Contains(got, pdir) && !strings.HasPrefix(got, os.TempDir()) {
t.Errorf("storeDir 应落在临时目录(%s 或 %s),实得 %q", pdir, os.TempDir(), got)
}
// ③ 环境变量必须真的被设上(机制而非巧合)
if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
t.Error("runtimePlugins 必须设 LLMSPROXY_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)")
}
}

View File

@ -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