mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
91
internal/core/channels_cline_adapter_test.go
Normal file
91
internal/core/channels_cline_adapter_test.go
Normal file
@ -0,0 +1,91 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Cline 的 429/402 话术与冷却时长(照参考 cline-rate-limit.ts)。
|
||||
//
|
||||
// ★ 这是**比参考更严重**的形态:参考退到"1 小时快照兜底",用户看到
|
||||
// 「永远 60 分钟」(等满一小时再试仍被拒,且每失败一次就从当下重新计一小时
|
||||
// ⇒ 无限循环)。而我们的内核把 429 当普通限流、用**固定 30 秒**冷却 ——
|
||||
// 那会变成**每 30 秒重试一次、永远失败**,在猛敲上游。
|
||||
//
|
||||
// 抓包真值(参考实测,no-retry: true —— 没有任何 retry-after 头):
|
||||
//
|
||||
// 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"}}
|
||||
func TestClineAdapterParsesProseWait(t *testing.T) {
|
||||
c, _ := poolCore(t)
|
||||
vm := c.VM()
|
||||
|
||||
// ① 当日免费额度用尽:必须被归一成**内核已认的中立短语**
|
||||
//(否则内核按 30 秒短冷却处理 ⇒ 每 30 秒猛敲上游)
|
||||
body := `{"error":{"code":"INFERENCE_CAP_ERROR","message":` +
|
||||
`"Error 429: Daily free limit reached on model deepseek/deepseek-v4-flash. Try again in 19h 39m"}}`
|
||||
out, ok, err := vm.TransformError("channels-cline", 429, body)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("transform_error: err=%v ok=%v", err, ok)
|
||||
}
|
||||
if !strings.Contains(out, "额度已用完") {
|
||||
t.Errorf("必须归一成内核已认的「额度已用完」(否则不会被当成额度耗尽处理),实得 %q", out)
|
||||
}
|
||||
// 真实等待时长必须出现在话术里(19h39m ≈ 19 小时)
|
||||
if !strings.Contains(out, "19") {
|
||||
t.Errorf("必须把真实等待时长写进话术(用户才知道要等多久),实得 %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "重试无用") {
|
||||
t.Errorf("当日额度用尽要明确告知重试无用(否则用户以为等一会就好),实得 %q", out)
|
||||
}
|
||||
// ⚠️ 绝不能出现参考踩过的「1 小时」误导
|
||||
if strings.Contains(out, "1 小时") && !strings.Contains(out, "19") {
|
||||
t.Errorf("不得退回快照式的「1 小时」误导,实得 %q", out)
|
||||
}
|
||||
|
||||
// ② 时长 token 必须**累加**(`1h 30m` = 90 分钟),不能只取第一个 ——
|
||||
// 通过真实的 transform_error 路径验证(不自己重算解析器)。
|
||||
acc, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Daily free limit reached. Try again in 1h 30m"}}`)
|
||||
if !strings.Contains(acc, "1 小时 30 分") {
|
||||
t.Errorf("`1h 30m` 必须累加成人话时长(1 小时 30 分),实得 %q", acc)
|
||||
}
|
||||
// 秒级也要能解析(否则秒级限流会退化成通用文案)
|
||||
secs, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Rate limited. Try again in 45s"}}`)
|
||||
if !strings.Contains(secs, "分") && !strings.Contains(secs, "秒") {
|
||||
t.Errorf("秒级时长应被解析并说出,实得 %q", secs)
|
||||
}
|
||||
|
||||
// ③ 402:等待不会恢复,必须说清楚要充值(不能只说"限流")
|
||||
out402, _, _ := vm.TransformError("channels-cline", 402,
|
||||
`{"error":{"message":"Insufficient credits"}}`)
|
||||
if !strings.Contains(out402, "充值") {
|
||||
t.Errorf("402 应明确提示需充值(等待无用),实得 %q", out402)
|
||||
}
|
||||
|
||||
// ④ 普通 429 带时长:如实说,但不谎报成额度耗尽
|
||||
out429, _, _ := vm.TransformError("channels-cline", 429,
|
||||
`{"error":{"message":"Rate limited. Try again in 30s"}}`)
|
||||
if strings.Contains(out429, "额度已用完") {
|
||||
t.Errorf("普通限流不得谎报成额度耗尽(那会让内核长时间冷却一个健康源),实得 %q", out429)
|
||||
}
|
||||
if !strings.Contains(out429, "限流") {
|
||||
t.Errorf("普通 429 应说成限流,实得 %q", out429)
|
||||
}
|
||||
|
||||
// ⑤ 多种外壳都要认(`{error:"…"}` 字符串 / `{msg}` / 非 JSON 纯文本)
|
||||
//(只认一种会在别的形态下静默丢掉整条信息)
|
||||
for _, shell := range []string{
|
||||
`{"error":"Daily free limit reached. Try again in 1h"}`,
|
||||
`{"msg":"Daily free limit reached. Try again in 1h"}`,
|
||||
`Daily free limit reached. Try again in 1h`,
|
||||
} {
|
||||
o, _, _ := vm.TransformError("channels-cline", 429, shell)
|
||||
if !strings.Contains(o, "额度已用完") {
|
||||
t.Errorf("外壳 %q 未被识别为额度耗尽,实得 %q", shell, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user