feat(channels): Qoder 用插件自装的 channels-qoder 适配器 —— 排队与额度耗尽分开说

## 为什么

Qoder 的错误信封是**双层编码**的,且两个业务码语义**恰好相反**:

| 码 | 含义 | 正确处置 |
|---|---|---|
| `10605` | 模型排队 | **暂时**受阻 —— 等若干秒就能过 |
| `110` | 当日额度耗尽 | 立刻重试 / 等 15 秒 / 等 30 分钟,结果一样 ⇒ **必须立即失败** |

参考实测(2026-09-27 用户报障):`110` 原先被归成通用的「服务端错误」→
**白重试 5 次**(≈15.5 秒),用户看到「重试延迟:7220毫秒」。
而我们此前用通用 `openai` 适配器,对它只能产出
`10605: {"isQueued":true,…}` —— 用户根本看不出是排队还是额度用完。

## 做法:厂商知识留在插件域(内核零厂商名)

照 `channels-trae` / `channels-gemini` 既有机制,本插件自装 `channels-qoder`。
内核只把 `transform_error` 的返回文本当作**客户端可读原因**,不认识任何厂商码。

★ **两个踩过的坑(都写进代码注释与判据)**:
1. **内层没有 `code` 字段** —— 那是 SSE 错误帧的 `message`(一个 JSON 字符串)。
   参考第一版要求「必须命中 code 才算排队」,导致内层形态被判成非排队,
   SSE 通道的修复**静默失效**(探针显示 sleep=0、错误照旧)。
   判据改为:命中 `code=10605`,**或**直接出现排队字段(`isQueued`/
   `serviceAvailable`)。`message`/`data`/`result`/`body` 都要展开并**再解析**。
2. **必须先在带排队字段的节点上取值** —— 外层 `root` 只有 `code`,
   没有 `retryAfterSeconds`/`queueCount`。一命中就返回会让这两个参数**永远丢失**
   (实测:只输出「排队中,请稍后重试」,参数全丢)。
3. **额度关键词必须窄** —— 只认 `billing daily count exceeded` /
   `daily count exceeded` / `billing_error`。`quota`/`balance` 这类泛词会把
   模型正文里讨论「余额」误判成故障。

## 判据(5 组,全部咬住真实行为)

`TestQoderAdapterParsesQueueAndBilling`:
① 双层编码取到内层的 30 秒 / 12 个排队;② **内层无 code** 也必须识别(参考的坑);
③ `110` 说成额度耗尽、且明确「重试无用」、**绝不说成排队**;
④ 泛词 `quota`/`balance` 不误伤;
⑤ **与通用 openai 适配器对比** —— 断言通用器**做不到**这件事,
   否则这个专用适配器就没有存在理由(防"加了但没用")。

回归:9 个包全绿;内核非测试代码零厂商名。
This commit is contained in:
JianFeeeee
2026-10-14 18:20:13 +08:00
parent 252e3486bd
commit 9521a35a03
2 changed files with 212 additions and 1 deletions

View File

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