feat(channels): Cline 用插件自装的 channels-cline 适配器 —— 解析英文句子里的等待时长

## 为什么(这是**比参考更严重**的形态)

Cline 的 429 **既不给 `retry-after` 头,也不给绝对时刻**,等待时长写在
**人类可读的英文句子**里。抓包真值(参考实测,`no-retry: true`):

```
HTTP 429
{"error":{"code":"INFERENCE_CAP_ERROR",
  "message":"Error 429: Daily free limit reached on model
             deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
```

- **参考**退到"1 小时快照兜底" ⇒ 用户看到「永远 60 分钟」(等满一小时再试仍被拒,
  且每失败一次就**从当下**重新计一小时 ⇒ 无限循环)。
- **我们更糟**:内核把 429 当普通限流、用**固定 30 秒**冷却 ⇒
  **每 30 秒重试一次、永远失败**,在猛敲上游。

## 做法:厂商知识留在插件域,产出归一成内核已认的中立语义

插件自装 `channels-cline`(照 trae/gemini/qoder 同一机制):

1. 解析真实等待时长 —— 各 token **累加**(`1h 30m` = 90 分钟),
   单位按**首字母**判(h/m/s 互不重叠)⇒ 一次扫描即可,不会重复计数
   (参考点名 `minutes?` 与 `m` 同时匹配会让时长翻倍)。
2. 把文案归一成内核**已认的中立短语**「额度已用完」⇒ 内核据此走
   `RecordQuotaExhausted`(冷却到窗口边界)而不是 30 秒限流冷却。
   **内核不认识任何厂商码/厂商文案**,只认自己的通用关键词表。
3. 话术把「是什么 + 何时恢复 + 重试无用」说清楚,**不**复述上游英文原文,
   也**不**退回「等 1 小时就好」那种误导。
4. 402 单独处理:等待**不会**恢复,必须说清楚需充值。

## 判据(5 组,含"不得谎报"的反向项)

`TestClineAdapterParsesProseWait`:
① 归一成「额度已用完」(否则内核按 30 秒猛敲)+ 真实时长出现 + 明确「重试无用」
  + **不得**退回「1 小时」误导;
② 通过**真实 transform_error 路径**验证时长累加(`1h 30m` → 「1 小时 30 分」)、
  秒级也能解析;
③ 402 提示充值;④ **普通 429 不得谎报成额度耗尽**(那会让内核长时间冷却一个
  健康源);⑤ 多种外壳(`{error:"…"}` / `{msg}` / 非 JSON 纯文本)都要认
  —— 只认一种会在别的形态下静默丢掉整条信息。

回归:9 个包全绿;内核非测试代码零厂商名。
This commit is contained in:
JianFeeeee
2026-10-14 18:44:51 +08:00
parent 81b3661336
commit 742e2b5ed3
2 changed files with 228 additions and 1 deletions

View File

@ -307,7 +307,11 @@ local PLATFORMS = {
auth = "bearer",
parse = "cline_merge",
},
adapter = "openai",
-- Cline 的 429 把等待时长写在**英文句子**里(`Try again in 19h 39m`),
-- 既无 retry-after 头也无绝对时刻。通用适配器解析不出 ⇒ 内核按 30 秒
-- 短冷却处理 ⇒ 每 30 秒重试、永远失败(在猛敲上游)。
-- 故用本插件自装的 channels-cline 把文案归一成内核已认的中立短语。
adapter = "channels-cline",
-- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费)
models = {
"stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor",
@ -8632,8 +8636,137 @@ end
return adapter
]==]
-- ============================================================================
-- Cline 专用适配器(插件自装:channels-cline)
-- ============================================================================
-- 为什么需要:Cline 的 429 **既不给 `retry-after` 头,也不给绝对时刻**,
-- 它把等待时长写在**人类可读的英文句子**里(参考实测抓包):
--
-- HTTP 429 no-retry: true ← 没有任何 retry-after 头
-- {"error":{"code":"INFERENCE_CAP_ERROR",
-- "message":"Error 429: Daily free limit reached on model
-- deepseek/deepseek-v4-flash. Try again in 19h 39m"}}
--
-- ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",于是用户看到
-- 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就**从当下**重新计一小时
-- ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
-- 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游(比参考更糟)。
--
-- 归一路径(厂商知识留在插件域,内核零厂商名):
-- ① 解析出真实等待时长(`19h 39m` 各 token **累加**);
-- ② 把文案归一成**内核已认的中立短语**("额度已用完"),
-- 于是内核走 RecordQuotaExhausted 而不是 30 秒限流冷却;
-- ③ 把真实原因(含"何时恢复")如实告诉用户 ——
-- 不说"等 1 小时就好",那正是参考踩过的坑。
--
-- ⚠️ 时长 token 必须**累加**且**不吃重复匹配**:`minutes?` 与 `m` 都能匹配
-- 同一段会让时长翻倍(参考注释点名)。我们用一次扫描 + 贪婪单位即可。
local CLINE_ADAPTER_SRC = [==[
local adapter = { name = "channels-cline", version = "1.0.0" }
function adapter.transform_request(raw_body) return raw_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
-- cline_wait_ms(text):把 `19h 39m` / `1h 30m` / `45s` 换成毫秒。
-- 各 token **累加**;不设上限(服务端说的就是它自己的结算窗口,
-- 替它裁剪只会又造出一个"以为等到了、其实没好"的倒计时)。
local function cline_wait_ms(text)
if type(text) ~= "string" then return nil end
local total, matched = 0, false
-- 单位按**首字母**判(h/m/s 互不重叠),故一次扫描即可,不会重复计数。
for num, unit in text:gmatch("(%d+)%s*([hms])[a-z]*") do
local n = tonumber(num)
if n then
local u = unit:lower()
if u == "h" then total = total + n * 3600000
elseif u == "m" then total = total + n * 60000
else total = total + n * 1000 end
matched = true
end
end
if not matched then return nil end
return total
end
-- 文案里出现这些标记 ⇒ 是"当日免费额度"而非普通限流。
-- 小写比对(照参考 CLINE_DAILY_FREE_MARKERS)。
local function cline_is_daily_free(text)
local low = string.lower(text or "")
return low:find("daily free limit", 1, true) ~= nil
or low:find("free daily limit", 1, true) ~= nil
or low:find("daily limit reached", 1, true) ~= nil
end
-- 从报文里取文案;必须认多种外壳(`{error:{message}}` / `{error:"…"}` /
-- `{message}` / `{msg}` / 非 JSON 纯文本)。
local function cline_message(body)
local okp, rec = pcall(json.decode, body or "")
if not okp or type(rec) ~= "table" then return tostring(body or "") end
local e = rec.error
if type(e) == "string" and e ~= "" then return e end
if type(e) == "table" and type(e.message) == "string" and e.message ~= "" then
return e.message
end
if type(rec.message) == "string" and rec.message ~= "" then return rec.message end
if type(rec.msg) == "string" and rec.msg ~= "" then return rec.msg end
return ""
end
-- fmt_duration(ms):人话时长("约 19 小时 39 分")。
local function fmt_duration(ms)
if ms == nil or ms <= 0 then return "" end
local h = math.floor(ms / 3600000)
local m = math.floor((ms % 3600000) / 60000)
if h > 0 then return "约 " .. h .. " 小时 " .. m .. " 分" end
if m > 0 then return "约 " .. m .. " 分" end
return "不到 1 分"
end
function adapter.transform_error(status, body)
local msg = cline_message(body)
local wait = cline_wait_ms(msg)
-- 当日免费额度用尽:等下去才会恢复(**不是**普通限流)。
-- ⚠️ 必须把话**说清楚**:内核按关键词识别"额度类"并据此冷却到窗口边界,
-- 若这里只说"rate limited",内核会用 30 秒短冷却 ⇒ 每 30 秒猛敲一次上游。
if cline_is_daily_free(msg) then
local when = wait and ("," .. fmt_duration(wait) .. " 后重置") or ""
-- "额度已用完" 是内核已认的中立短语(quotaKeywords)⇒ 走窗口冷却而非 30s。
return "额度已用完:" .. tostring(status) .. " 当日免费额度已用尽(HTTP "
.. tostring(status) .. ")" .. when .. ",重试无用"
end
-- 402:等下去不会恢复(要充值)
if status == 402 then
return "额度已用完:Insufficient credits(HTTP 402)—— 等待不会恢复,需充值"
end
-- 其它 429:有明确等待时长就如实说(内核仍按普通限流短冷却,但用户知道要等多久)
if status == 429 and wait and wait > 0 then
return "限流(HTTP 429)" .. fmt_duration(wait) .. "后可重试"
end
return __prelude.openai_error(status, body)
end
-- 导出给判据用(不是内核契约的一部分)。
adapter.cline_wait_ms = cline_wait_ms
return adapter
]==]
-- installQoderAdapter():装/更新本插件自己的 Qoder 适配器。
-- 与 trae/gemini 同一机制(插件自装、厂商知识留在插件域)。
-- installClineAdapter():装/更新本插件自己的 Cline 适配器。
local function installClineAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(CLINE_ADAPTER_SRC)
if not f then return false, "源码语法错误: " .. tostring(lerr) end
local _, uerr = __llmsproxy_adapters.update("channels-cline", CLINE_ADAPTER_SRC)
if uerr then
local _, ierr = __llmsproxy_adapters.install("channels-cline", CLINE_ADAPTER_SRC)
if ierr then return false, ierr end
end
return true, nil
end
local function installQoderAdapter()
if not __llmsproxy_adapters then return false, "无适配器桥(旧内核?)" end
local f, lerr = load(QODER_ADAPTER_SRC)
@ -9600,6 +9733,9 @@ do
plugin._gemini_adapter = { ok = gok, err = gerr }
local qok, qerr = installQoderAdapter()
plugin._qoder_adapter = { ok = qok, err = qerr }
local cok, cerr = installClineAdapter()
plugin._cline_adapter = { ok = cok, err = cerr }
if not cok then logEvent("Cline 适配器装载失败: " .. tostring(cerr)) end
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