diff --git a/internal/core/channels_cline_adapter_test.go b/internal/core/channels_cline_adapter_test.go new file mode 100644 index 0000000..a2855bf --- /dev/null +++ b/internal/core/channels_cline_adapter_test.go @@ -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) + } + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index a213eb8..b4ef9d0 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -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