mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user