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