diff --git a/internal/core/channels_qoder_adapter_test.go b/internal/core/channels_qoder_adapter_test.go new file mode 100644 index 0000000..5879094 --- /dev/null +++ b/internal/core/channels_qoder_adapter_test.go @@ -0,0 +1,88 @@ +package core + +import ( + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// qoderReason 调真实的 transform_error 钩子(不自己重算)。 +func qoderReason(t *testing.T, vm *lua.VM, status int, body string) string { + t.Helper() + reason, ok, err := vm.TransformError("channels-qoder", status, body) + if err != nil { + t.Fatalf("transform_error 调用失败: %v", err) + } + if !ok { + t.Fatalf("channels-qoder 适配器未装载或未定义 transform_error(body=%s)", body) + } + return reason +} + +// Qoder 的错误信封是**双层编码**的,且两个业务码语义**相反**: +// +// 10605 排队 → 暂时受阻,等一下就好 +// 110 额度 → 当天耗尽,重试无用(参考实测:原先归成通用服务端错误 → 白重试 5 次) +// +// 通用 openai 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` —— +// 用户看不懂是排队还是额度用完。故用**插件自装的 channels-qoder**, +// 厂商码值留在插件域(内核零厂商名)。 +func TestQoderAdapterParsesQueueAndBilling(t *testing.T) { + c, _ := poolCore(t) + vm := c.VM() + + // ① 排队:外层 code=10605 + 内层 JSON 字符串(参考实测的真实形状) + out := qoderReason(t, vm, 403, + `{"code":"10605","message":"{\"isQueued\":true,\"queueCount\":12,\"retryAfterSeconds\":30}"}`) + if !strings.Contains(out, "排队") { + t.Errorf("10605 必须说成排队,实得 %q", out) + } + // 等待时长与队列长度要从**内层**取到(只读顶层永远拿不到) + if !strings.Contains(out, "30") { + t.Errorf("应从内层取到 retryAfterSeconds=30,实得 %q", out) + } + if !strings.Contains(out, "12") { + t.Errorf("应从内层取到 queueCount=12,实得 %q", out) + } + + // ② SSE 错误帧形态:**内层没有 code 字段**,只有排队标志。 + // ⚠️ 参考第一版要求「必须命中 code 才算排队」,导致这一形态被判成非排队、 + // SSE 通道的修复静默失效(探针显示 sleep=0)。这里必须能识别。 + out2 := qoderReason(t, vm, 200, + `{"message":"{\"isQueued\":true,\"queueCount\":3,\"retryAfterSeconds\":8}"}`) + if !strings.Contains(out2, "排队") { + t.Errorf("内层无 code 也必须识别为排队(这是参考踩过的坑),实得 %q", out2) + } + if !strings.Contains(out2, "8") { + t.Errorf("应取到内层 retryAfterSeconds=8,实得 %q", out2) + } + + // ③ 额度耗尽:必须与排队**分开说**,且明确"重试无用" + out3 := qoderReason(t, vm, 403, `{"code":"110","message":"Billing daily count exceeded"}`) + if !strings.Contains(out3, "额度") { + t.Errorf("110 必须说成额度耗尽,实得 %q", out3) + } + if strings.Contains(out3, "排队") { + t.Errorf("110 绝不能被说成排队(两者语义相反),实得 %q", out3) + } + if !strings.Contains(out3, "重试无用") { + t.Errorf("额度耗尽要明确告知重试无用,实得 %q", out3) + } + + // ④ 泛词不能误伤:正文里讨论"配额/余额"不该被当成额度错误 + //(关键词必须窄 —— 宽词会把模型正常输出误判成故障) + out4 := qoderReason(t, vm, 500, + `{"error":{"message":"the response discusses quota and balance concepts"}}`) + if strings.Contains(out4, "额度已用尽") { + t.Errorf("泛词 quota/balance 不得误伤(关键词必须窄),实得 %q", out4) + } + + // ⑤ 与通用 openai 适配器对比:证明**通用器确实做不到**这件事 + //(否则这个专用适配器就没有存在理由) + gen, okGen, _ := vm.TransformError("openai", 403, + `{"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}`) + if okGen && strings.Contains(gen, "排队") { + t.Error("通用 openai 适配器不该能识别排队 —— 若能,则专用适配器无必要") + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 16e784d..9891e9c 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -762,7 +762,10 @@ local PLATFORMS = { }, -- qoder 的 chat 路径是 /model/v1/chat/completions(参考 QODER_CHAT_PATH)。 base_url = "https://api2-v2.qoder.sh/model/v1/chat/completions", - adapter = "openai", + -- Qoder 的错误信封是**双层编码**的,且 10605(排队) 与 110(额度耗尽) + -- 语义相反 —— 通用 openai 适配器只能看到外层,产出用户看不懂的原因串。 + -- 故用本插件自装的 channels-qoder(厂商码留在插件域,内核零厂商名)。 + adapter = "channels-qoder", -- 兜底目录(照参考 QODER_FALLBACK_MODELS 的真实目录 key)。 -- 关键:这些是**上游目录 key**(如 qfmodel),不是通用名 —— 公开的 -- /model/v1/chat/completions 不认它们,必须走加密端点 agent_chat_generation。 @@ -8435,6 +8438,123 @@ end return adapter ]==] +-- ============================================================================ +-- Qoder 专用适配器(插件自装:channels-qoder) +-- ============================================================================ +-- 为什么 Qoder 需要自己的适配器:它的错误信封是**双层编码的**,通用 openai +-- 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` 这种**用户看不懂** +-- 的原因串 —— 而 10605 与 110 的语义**恰好相反**: +-- +-- 10605 排队 → **暂时**受阻,等若干秒就能过(应等待重试) +-- 110 额度 → **当天耗尽**,立刻重试 / 等 15 秒 / 等 30 分钟, +-- 结果完全一样(故必须立即失败并如实告诉用户) +-- +-- 参考实测(model-queue.ts,2026-09-27 用户报障):110 原先被归成通用的 +-- 「服务端错误」→ **白重试 5 次**(≈15.5 秒),用户看到「重试延迟:7220毫秒」。 +-- +-- ⚠️ 关键结构:`message` 是**一个 JSON 字符串**(内层还要再解一次): +-- {"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"} +-- 且**内层没有 code 字段** —— 参考第一版要求「必须命中 code 才算排队」, +-- 导致内层形态被判成非排队,SSE 通道的修复**静默失效**(探针显示 sleep=0)。 +-- ⇒ 判据必须是:命中 `code=10605`,**或**直接出现排队字段(isQueued / +-- serviceAvailable)。 +-- +-- 厂商码值留在**插件**里(内核零厂商名)—— 内核只把 transform_error 返回的 +-- 文本当作客户端可读原因;额度类语义由内核既有的关键词表识别(quota/billing +-- 等通用英文短语),故这里只要把话**说清楚**即可。 +local QODER_ADAPTER_SRC = [==[ +local adapter = { name = "channels-qoder", version = "1.0.0" } + +-- 出向就是标准 OpenAI 形状(Qoder 的 chat 端点是 OpenAI 兼容的), +-- 故转换钩子直接复用前导助手。 +function adapter.transform_request(raw_body) + local body = __prelude.decode_body(raw_body) + if body == nil then return raw_body end + __prelude.strip_control(body) + return json.encode(body) +end +function adapter.transform_response(raw_body) return __prelude.openai_response(raw_body) end +function adapter.transform_stream_chunk(raw_chunk) return __prelude.openai_stream_chunk(raw_chunk) end + +-- 厂商码值只出现在**这个适配器**里(内核零厂商名)。 +local QODER_QUEUE_CODE = "10605" + +-- qoder_unwrap(root):广度优先展开 data/result/message/body。 +-- ⚠️ 必须展开 `message` 这类**字符串再解析**的分支:只读顶层永远拿不到内层 +-- 排队参数(参考第一版漏了这条,SSE 通道的修复静默失效、探针显示 sleep=0)。 +local function qoder_unwrap(root) + local seen, nodes, queue = {}, {}, { root } + while #queue > 0 do + local node = table.remove(queue, 1) + if type(node) == "table" and not seen[node] then + seen[node] = true + nodes[#nodes + 1] = node + for _, k in ipairs({ "data", "result", "message", "body" }) do + local v = node[k] + if type(v) == "table" then + queue[#queue + 1] = v + elseif type(v) == "string" and v ~= "" then + local okp, parsed = pcall(json.decode, v) + if okp and type(parsed) == "table" then queue[#queue + 1] = parsed end + end + end + end + end + return nodes +end + +function adapter.transform_error(status, body) + local okp, root = pcall(json.decode, body or "") + if okp and type(root) == "table" then + -- ⚠️ 判据:命中顶层 code=10605,**或**直接出现排队字段。 + -- 内层(SSE 错误帧的 message)**没有 code 字段** —— 若要求必须命中 code, + -- 内层形态会被判成非排队,修复静默失效。 + local topHit = tostring(root.code) == QODER_QUEUE_CODE + -- ⚠️ 必须**先找带排队字段的那个节点**,不能一命中就返回: + -- 外层节点(root)本身只有 code,没有 retryAfterSeconds/queueCount, + -- 若在 root 上就返回,等待时长与队列长度**永远取不到**(实测踩到: + -- 只输出"排队中,请稍后重试",参数全丢)。 + for _, n in ipairs(qoder_unwrap(root)) do + if n.isQueued ~= nil or n.serviceAvailable ~= nil then + local ra = tonumber(n.retryAfterSeconds) or tonumber(n.retryAfter) or 0 + local qc = tonumber(n.queueCount) or 0 + if ra > 0 then + return "qoder 模型排队中(前面 " .. tostring(qc) .. " 个),约 " + .. tostring(ra) .. " 秒后重试" + end + return "qoder 模型排队中,请稍后重试" + end + end + -- 只有 code 命中、没有任何排队字段:仍然算排队(码值本身是权威), + -- 但参数确实拿不到,如实说明。 + if topHit then return "qoder 模型排队中,请稍后重试" end + -- 额度类:关键词必须**窄**(`quota`/`balance` 泛词会误伤模型正文) + local low = string.lower(body or "") + if low:find("billing daily count exceeded", 1, true) + or low:find("daily count exceeded", 1, true) + or low:find("billing_error", 1, true) then + return "qoder 当日额度已用尽(明日恢复),重试无用" + end + end + return __prelude.openai_error(status, body) +end +return adapter +]==] + +-- installQoderAdapter():装/更新本插件自己的 Qoder 适配器。 +-- 与 trae/gemini 同一机制(插件自装、厂商知识留在插件域)。 +local function installQoderAdapter() + if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end + local f, lerr = load(QODER_ADAPTER_SRC) + if not f then return false, "源码语法错误: " .. tostring(lerr) end + local _, uerr = __llmsproxy_adapters.update("channels-qoder", QODER_ADAPTER_SRC) + if uerr then + local _, ierr = __llmsproxy_adapters.install("channels-qoder", QODER_ADAPTER_SRC) + if ierr then return false, ierr end + end + return true, nil +end + -- installGeminiAdapter():装/更新本插件自己的 Gemini 私有协议适配器。 local function installGeminiAdapter() if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end @@ -9345,7 +9465,10 @@ do plugin._trae_adapter = { ok = tok2, err = terr2 } local gok, gerr = installGeminiAdapter() plugin._gemini_adapter = { ok = gok, err = gerr } + local qok, qerr = installQoderAdapter() + plugin._qoder_adapter = { ok = qok, err = qerr } if not gok then logEvent("Gemini 适配器装载失败: " .. tostring(gerr)) end + if not qok then logEvent("Qoder 适配器装载失败: " .. tostring(qerr)) end if not tok2 then logEvent("TRAE 适配器装载失败: " .. tostring(terr2)) end end