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 "", "<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.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 "", "<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 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