diff --git a/internal/core/channels_pool_test.go b/internal/core/channels_pool_test.go
index a7b61bd..7462ba5 100644
--- a/internal/core/channels_pool_test.go
+++ b/internal/core/channels_pool_test.go
@@ -124,13 +124,18 @@ func TestChannelsAccountPoolBehavior(t *testing.T) {
if !ok {
t.Fatal("带凭据账号应已同步源")
}
- if n := len(asArr(sm["models"])); n != 3 {
- t.Fatalf("源应有3模型, 实得 %d", n)
+ // 不写死具体数量:兜底目录是照参考 *-product.ts 补的,会随对齐
+ // 进度变化(minimax 已从 3 项补到 4 项)。这里断言的是**关系**:
+ // 关掉一个模型,源里就少一个。
+ all := invoke(t, ps, "model_list", `{"platform":"minimax"}`)
+ total := len(asArr(all["models"]))
+ if n := len(asArr(sm["models"])); n != total {
+ t.Fatalf("源应含全部 %d 个模型, 实得 %d", total, n)
}
invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`)
sm, _ = srcHasAccount(listSourcesViaKernel(t, c), aid)
- if n := len(asArr(sm["models"])); n != 2 {
- t.Fatalf("关1模型后源应2模型, 实得 %d", n)
+ if n := len(asArr(sm["models"])); n != total-1 {
+ t.Fatalf("关1模型后源应 %d 模型, 实得 %d", total-1, n)
}
invoke(t, ps, "model_set_all_disabled", `{"platform":"minimax","disabled":true}`)
m = invoke(t, ps, "provider_status", `{}`)
diff --git a/internal/lua/adapter_prelude.go b/internal/lua/adapter_prelude.go
index 8c83475..4089ce6 100644
--- a/internal/lua/adapter_prelude.go
+++ b/internal/lua/adapter_prelude.go
@@ -157,6 +157,195 @@ function __prelude.tool_id(id)
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.
… 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 错误页取 ,否则取首个非空非标签行
+ local title = string.match(body or "", "(.-)")
+ 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.derive_key:由 base 派生一个稳定的、长度受限的标识(如 client_session)。
function __prelude.derive_key(base, n)
n = n or 32
diff --git a/internal/lua/adapter_prelude_equivalence_test.go b/internal/lua/adapter_prelude_equivalence_test.go
new file mode 100644
index 0000000..6081230
--- /dev/null
+++ b/internal/lua/adapter_prelude_equivalence_test.go
@@ -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)
+ }
+}
diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua
index e22b9d4..7c0d9c6 100644
--- a/internal/lua/adapters/deepseek.lua
+++ b/internal/lua/adapters/deepseek.lua
@@ -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
diff --git a/internal/lua/adapters/github.lua b/internal/lua/adapters/github.lua
index 03b609b..33cec83 100644
--- a/internal/lua/adapters/github.lua
+++ b/internal/lua/adapters/github.lua
@@ -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
diff --git a/internal/lua/adapters/groq.lua b/internal/lua/adapters/groq.lua
index 53fd8ad..252603b 100644
--- a/internal/lua/adapters/groq.lua
+++ b/internal/lua/adapters/groq.lua
@@ -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
diff --git a/internal/lua/adapters/kimicode.lua b/internal/lua/adapters/kimicode.lua
index 3402898..1509a1e 100644
--- a/internal/lua/adapters/kimicode.lua
+++ b/internal/lua/adapters/kimicode.lua
@@ -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
diff --git a/internal/lua/adapters/mistral.lua b/internal/lua/adapters/mistral.lua
index d90986b..a40b0d6 100644
--- a/internal/lua/adapters/mistral.lua
+++ b/internal/lua/adapters/mistral.lua
@@ -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)
diff --git a/internal/lua/adapters/openai.lua b/internal/lua/adapters/openai.lua
index 3022e58..56038b2 100644
--- a/internal/lua/adapters/openai.lua
+++ b/internal/lua/adapters/openai.lua
@@ -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 text (e.g. "413 Request Entity Too Large")
- local title = string.match(body or "", "(.-)")
- 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
diff --git a/internal/lua/adapters/sensenova.lua b/internal/lua/adapters/sensenova.lua
index 1840e14..7479640 100644
--- a/internal/lua/adapters/sensenova.lua
+++ b/internal/lua/adapters/sensenova.lua
@@ -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