diff --git a/internal/core/channels_autoclaim_cover_test.go b/internal/core/channels_autoclaim_cover_test.go new file mode 100644 index 0000000..37d8df2 --- /dev/null +++ b/internal/core/channels_autoclaim_cover_test.go @@ -0,0 +1,111 @@ +package core + +import ( + "strings" + "testing" +) + +// ★ 自动领取的「今天已处理」记账(照参考 coversToday / shouldMarkToday)。 +// +// 真实缺陷(参考 2026-10-02 定位,每天静默漏 100 积分):Qoder 的活动 +// **每日 10:00(UTC+8)才刷新**,而自动签到在上午跑 —— 上午看到的是**昨天** +// 那条 CLAIMED,返回 `already-claimed`。若拿它记账,当天 10 点刷新后就再也 +// 不去领。 +// +// 修法不靠「给每个渠道配刷新时刻表」(必然漂移),而是让**产出方自己回答** +// 「我这条结果覆盖的是不是今天这一轮」(covers_today),记账方只数 +// 「有几条能证明今天已被处理」。 +func TestAutoClaimCountsCoversTodayNotKind(t *testing.T) { + _, ps := poolCore(t) + + // ① Qoder 在刷新前必须声明 covers_today=false(否则这条链就白修) + out, err := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{ + "refreshed": false, + }) + if err != nil { + t.Fatalf("探针: %v", err) + } + j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) + if j == nil || j["kind"] == nil { + t.Fatalf("探针应返回 kind,实得 %#v", out) + } + if j["covers_today"] != false { + t.Errorf("刷新前必须声明 covers_today=false(这正是漏领的根因),实得 %v", j["covers_today"]) + } + if j["kind"] == "already-claimed" { + t.Error("刷新前**绝不能**报 already-claimed(那会被记账成「今天已处理」)") + } + + // ② 刷新后:正常报已领,且 covers_today=true + out2, _ := ps.Call("channels", "__qoder_claim_outcome_probe", map[string]interface{}{ + "refreshed": true, + }) + j2, _ := out2.(map[string]interface{})["json"].(map[string]interface{}) + if j2["covers_today"] != true { + t.Errorf("刷新后应声明 covers_today=true,实得 %v", j2["covers_today"]) + } + + // ③ 记账判据必须**只看 covers_today**,不能用 kind 反推 + //(`already-claimed` 天然有歧义 —— 可能就是昨天那一轮的痕迹) + cases := []struct { + name string + kind string + cov interface{} + want bool + }{ + {"刷新前的 already-claimed 不得算已处理", "already-claimed", false, false}, + {"刷新后的 already-claimed 算已处理", "already-claimed", true, true}, + {"success 算已处理", "success", true, true}, + {"covers_today 缺省为 true", "claimed", nil, true}, + {"inactive 不算已处理", "inactive", true, false}, + {"failed 不算已处理", "failed", true, false}, + } + for _, c := range cases { + o, err := ps.Call("channels", "__claim_covers_probe", map[string]interface{}{ + "kind": c.kind, "covers_today": c.cov, + }) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + oj, _ := o.(map[string]interface{})["json"].(map[string]interface{}) + if got, _ := oj["covers"].(bool); got != c.want { + t.Errorf("%s: 应 %v,实得 %v", c.name, c.want, got) + } + } +} + +// 调度频率:一天只跑一次会让「刷新时刻晚于执行时刻」的平台整天漏领。 +func TestAutoClaimScheduledMoreThanOnceDaily(t *testing.T) { + _, ps := poolCore(t) + out, err := ps.Call("channels", "__schedule_probe", nil) + if err != nil { + t.Fatalf("探针: %v", err) + } + j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) + every, _ := j["auto_claim_every"].(float64) + if every <= 0 { + t.Fatalf("找不到 auto_claim 的调度间隔,实得 %v", j) + } + // 必须显著小于一天:活动刷新时刻各平台不同(Qoder 是每日 10:00 UTC+8), + // 一天只跑一次就必然有平台漏领。 + if every >= 86400 { + t.Errorf("auto_claim 间隔应远小于一天(否则刷新晚的平台整天漏领),实得 %.0f 秒", every) + } + if j["first_due_now"] != true { + t.Error("auto_claim 应 first_due_now(重启立即补跑,不等一个周期)") + } + // 已覆盖今天的平台必须靠记账跳过,否则会重复打上游 + if !strings.Contains(toString(j["has_coverage_key"]), "true") { + t.Error("需要「今天已覆盖」的持久化记账键") + } +} + +func toString(v interface{}) string { + if b, ok := v.(bool); ok { + if b { + return "true" + } + return "false" + } + return "" +} diff --git a/internal/core/channels_autoclaim_test.go b/internal/core/channels_autoclaim_test.go index e3168b1..84d7b7e 100644 --- a/internal/core/channels_autoclaim_test.go +++ b/internal/core/channels_autoclaim_test.go @@ -106,9 +106,18 @@ func TestAutoClaimScheduledWithModelRefresh(t *testing.T) { } return "" } - // auto_claim 是每日任务,**不该** first_due_now(重启就重领是错的) - if ln := lineOf("auto_claim"); strings.Contains(ln, "first_due_now") { - t.Errorf("auto_claim 不该 first_due_now(每日任务重启后重领是错的):%s", ln) + // auto_claim **现在**也 first_due_now,且间隔远小于一天。 + // + // ★ 这里改过一次,原因值得记下:原判据写的是「auto_claim 是每日任务, + // 不该 first_due_now(重启就重领是错的)」—— 那个假设**本身是缺陷的一部分**。 + // 活动刷新时刻**各平台不同**(Qoder 是每日 10:00 UTC+8),一天只跑一次 + // 必然有平台整天漏领(参考实测:每天静默漏 100 积分)。修法有两半: + // ① 频率提高 + first_due_now(任何时刻重启都能补上); + // ② 「今天已覆盖」靠 covers_today 记账跳过(**这才是防重领的正确机制**, + // 而不是靠"一天只跑一次"来碰巧避开)。 + // 只做 ② 不做 ① 仍会漏领;只做 ① 不做 ② 会重复打上游。 + if ln := lineOf("auto_claim"); !strings.Contains(ln, "first_due_now = true") { + t.Errorf("auto_claim 应 first_due_now(否则重启后当天漏领):%s", ln) } // 两个自动任务必须 first_due_now(否则重启要等满一轮才自愈) for _, fn := range []string{"refresh_models", "refresh_creds"} { 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/core/channels_free_flag_test.go b/internal/core/channels_free_flag_test.go new file mode 100644 index 0000000..5cf6e21 --- /dev/null +++ b/internal/core/channels_free_flag_test.go @@ -0,0 +1,67 @@ +package core + +import ( + "testing" +) + +// `isFree` 的判定链(照参考 cline-models.ts)。 +// +// ★ 修的是一个与注释自相矛盾的缺陷:cline 的 3 个模型来自远端 **free 数组**, +// 却声明在 `models` 里 ⇒ 被标成收费。用户看不到哪个可以不花钱用。 +// +// 参考的判定链(权威在前): +// +// ① 远端 free 数组(`recommended-models` 是**唯一权威**的免费集合; +// `/api/v1/models` 的 460 个 id 里根本没有 cline-free/*) +// ② 命名约定前缀 `cline-free/*`(远端未响应时的兜底) +// ③ 声明的 models_free +// ④ 能力表的 cost(models.dev) +// +// ⚠️ 免费是 **OR** 语义:多个来源任一确认免费就够。写成"后者覆盖前者"会让 +// 「远端说免费、能力表拉不到」退回收费 —— 正是这类静默降级。 +func TestIsFreeUsesAuthorityChain(t *testing.T) { + _, ps := poolCore(t) + + // cline 的免费模型必须被标成免费(远端/free_prefix/声明 任一命中即可) + out, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": "cline"}) + if err != nil { + t.Fatalf("model_list: %v", err) + } + j, _ := out.(map[string]interface{})["json"].(map[string]interface{}) + got := map[string]bool{} + for _, it := range asArrInterface(j["models"]) { + if mm, ok := it.(map[string]interface{}); ok { + if id, ok := mm["id"].(string); ok { + if f, ok := mm["isFree"].(bool); ok { + got[id] = f + } + } + } + } + // `cline-free/*` 前缀命中 ⇒ 必须免费(命名约定兜底) + if !got["cline-free/mimo-v2.6-flash"] { + t.Errorf("cline-free/* 前缀的模型必须免费(命名约定兜底),实得 %v", got) + } + // stealth/space-bunny-alpha 来自远端 free 数组,也必须免费 + if !got["stealth/space-bunny-alpha"] { + t.Errorf("远端 free 数组里的模型必须免费,实得 %v", got) + } + + // 命名约定必须生效(远端未响应时唯一的兜底依据) + probe, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{ + "platform": "cline", "model_id": "cline-free/some-new-model", + }) + pj, _ := probe.(map[string]interface{})["json"].(map[string]interface{}) + if pj["is_free"] != true { + t.Error("远端未响应时,`cline-free/*` 前缀必须能判出免费(这是兜底依据)") + } + // 反向:收费通道的同名模型**不得**被判免费 + //(参考点名:免费模型是独立 id,`cline-free/x` 与 `x` 是两个不同模型) + probe2, _ := ps.Call("channels", "__is_free_probe", map[string]interface{}{ + "platform": "cline", "model_id": "deepseek/deepseek-v4.1-flash", + }) + pj2, _ := probe2.(map[string]interface{})["json"].(map[string]interface{}) + if pj2["is_free"] == true { + t.Error("收费通道的模型不得因为前缀规则被判成免费") + } +} diff --git a/internal/core/channels_qoder_adapter_test.go b/internal/core/channels_qoder_adapter_test.go new file mode 100644 index 0000000..5879094 --- /dev/null +++ b/internal/core/channels_qoder_adapter_test.go @@ -0,0 +1,88 @@ +package core + +import ( + "strings" + "testing" + + "llmsproxy/internal/lua" +) + +// qoderReason 调真实的 transform_error 钩子(不自己重算)。 +func qoderReason(t *testing.T, vm *lua.VM, status int, body string) string { + t.Helper() + reason, ok, err := vm.TransformError("channels-qoder", status, body) + if err != nil { + t.Fatalf("transform_error 调用失败: %v", err) + } + if !ok { + t.Fatalf("channels-qoder 适配器未装载或未定义 transform_error(body=%s)", body) + } + return reason +} + +// Qoder 的错误信封是**双层编码**的,且两个业务码语义**相反**: +// +// 10605 排队 → 暂时受阻,等一下就好 +// 110 额度 → 当天耗尽,重试无用(参考实测:原先归成通用服务端错误 → 白重试 5 次) +// +// 通用 openai 适配器只能看到外层,产出 `10605: {"isQueued":true,…}` —— +// 用户看不懂是排队还是额度用完。故用**插件自装的 channels-qoder**, +// 厂商码值留在插件域(内核零厂商名)。 +func TestQoderAdapterParsesQueueAndBilling(t *testing.T) { + c, _ := poolCore(t) + vm := c.VM() + + // ① 排队:外层 code=10605 + 内层 JSON 字符串(参考实测的真实形状) + out := qoderReason(t, vm, 403, + `{"code":"10605","message":"{\"isQueued\":true,\"queueCount\":12,\"retryAfterSeconds\":30}"}`) + if !strings.Contains(out, "排队") { + t.Errorf("10605 必须说成排队,实得 %q", out) + } + // 等待时长与队列长度要从**内层**取到(只读顶层永远拿不到) + if !strings.Contains(out, "30") { + t.Errorf("应从内层取到 retryAfterSeconds=30,实得 %q", out) + } + if !strings.Contains(out, "12") { + t.Errorf("应从内层取到 queueCount=12,实得 %q", out) + } + + // ② SSE 错误帧形态:**内层没有 code 字段**,只有排队标志。 + // ⚠️ 参考第一版要求「必须命中 code 才算排队」,导致这一形态被判成非排队、 + // SSE 通道的修复静默失效(探针显示 sleep=0)。这里必须能识别。 + out2 := qoderReason(t, vm, 200, + `{"message":"{\"isQueued\":true,\"queueCount\":3,\"retryAfterSeconds\":8}"}`) + if !strings.Contains(out2, "排队") { + t.Errorf("内层无 code 也必须识别为排队(这是参考踩过的坑),实得 %q", out2) + } + if !strings.Contains(out2, "8") { + t.Errorf("应取到内层 retryAfterSeconds=8,实得 %q", out2) + } + + // ③ 额度耗尽:必须与排队**分开说**,且明确"重试无用" + out3 := qoderReason(t, vm, 403, `{"code":"110","message":"Billing daily count exceeded"}`) + if !strings.Contains(out3, "额度") { + t.Errorf("110 必须说成额度耗尽,实得 %q", out3) + } + if strings.Contains(out3, "排队") { + t.Errorf("110 绝不能被说成排队(两者语义相反),实得 %q", out3) + } + if !strings.Contains(out3, "重试无用") { + t.Errorf("额度耗尽要明确告知重试无用,实得 %q", out3) + } + + // ④ 泛词不能误伤:正文里讨论"配额/余额"不该被当成额度错误 + //(关键词必须窄 —— 宽词会把模型正常输出误判成故障) + out4 := qoderReason(t, vm, 500, + `{"error":{"message":"the response discusses quota and balance concepts"}}`) + if strings.Contains(out4, "额度已用尽") { + t.Errorf("泛词 quota/balance 不得误伤(关键词必须窄),实得 %q", out4) + } + + // ⑤ 与通用 openai 适配器对比:证明**通用器确实做不到**这件事 + //(否则这个专用适配器就没有存在理由) + gen, okGen, _ := vm.TransformError("openai", 403, + `{"code":"10605","message":"{\"isQueued\":true,\"retryAfterSeconds\":30}"}`) + if okGen && strings.Contains(gen, "排队") { + t.Error("通用 openai 适配器不该能识别排队 —— 若能,则专用适配器无必要") + } +} diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 16e784d..5703084 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "account_adopt", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "__is_model_gone_probe", "__rank_accounts_probe", "__inject_refresh_platform_probe", "__clear_capabilities_probe", "__qoder_claim_outcome_probe", "__is_free_probe", "__claim_covers_probe", "__schedule_probe", "__parse_capability_probe", "__inject_capability_probe", "__ttl_reset_probe", "__ttl_probe", "__ttl_invalidate_probe", "__serial_probe", "__serial_release_probe", "__set_legacy_cred_probe", "__read_cred_probe", "__clear_all_creds_probe", "credits_tier_probe", "__set_lock_permanent_probe", "__set_expiring_window_probe", "__expiring_window_probe", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -307,11 +307,24 @@ local PLATFORMS = { auth = "bearer", parse = "cline_merge", }, - adapter = "openai", - -- 免费模型(远端 free 数组实测,2026-10-05;内嵌目录只有这 3 个免费) + -- Cline 的 429 把等待时长写在**英文句子**里(`Try again in 19h 39m`), + -- 既无 retry-after 头也无绝对时刻。通用适配器解析不出 ⇒ 内核按 30 秒 + -- 短冷却处理 ⇒ 每 30 秒重试、永远失败(在猛敲上游)。 + -- 故用本插件自装的 channels-cline 把文案归一成内核已认的中立短语。 + adapter = "channels-cline", + -- 命名约定:`cline-free/*` 是**免费通道**的独立 id(照参考 + -- cline-models.ts 的 isFree 兜底规则)。远端 free 数组未响应时靠它判免费。 + free_prefix = "cline-free/", + -- 这 3 个来自远端 free 数组(内嵌目录里没有),**它们是免费的** + -- (此前误放在 models 里 ⇒ 被标成收费,与注释自相矛盾)。 models = { "stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor", }, + -- 显式声明免费集合:这是**兜底**(远端 free 数组是权威,但可能拉不到)。 + -- 三者都在这里 ⇒ 即使远端抖动也不会把免费模型误标成收费。 + models_free = { + "stealth/space-bunny-alpha", "cline-free/mimo-v2.6-flash", "cline-free/muse-spark-1.3-contributor", + }, -- 参考 cline-credits.ts:sidecar 里扫不到 checkin/campaign 端点 —— 无每日领取。 -- 余额(对齐参考 cline-quota.ts:GET usage-limits,单位 dollars,show percentUsed) credit_unit = "dollars", @@ -675,6 +688,9 @@ local PLATFORMS = { adapter = "openai", -- models.dev 的键名(能力元数据的来源;照参考 opencode-capability) capability_key = "opencode", + -- 该平台没有自己的免费名单,models.dev 的 cost **就是**唯一权威 + -- (照参考 opencode-capability.ts)。故本地 models_free 只作兜底。 + free_source = "capability", -- 照参考 OPENCODE_FALLBACK_MODELS 全量(14 项)。isFree 是**官方语义 -- 字段**(匿名可用与否):7 个 free 无需 key,其余需付费 key(实测匿名 401)。 models = { @@ -762,7 +778,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。 @@ -1643,11 +1662,22 @@ local function qoderClaim(plat, tok, args) end if claimedBefore then if not refreshed then - return { done = true, kind = "inactive", + -- ★ covers_today = false:这次看到的是**刷新前那一轮**的痕迹, + -- 因此**不得**据此认定"今天已处理"。 + -- + -- 参考的真实缺陷(2026-10-02,每天静默漏领 100 积分):上游 10:00(UTC+8) + -- 才刷新活动,而自动签到在上午跑 —— 上午那次看到的是**昨天**那条 CLAIMED。 + -- 若拿它记账,当天 10 点刷新后就再也不会去领。 + -- + -- 修法不靠"给每个渠道配刷新时刻表"(必然漂移),而是**让产出方自己 + -- 回答**"我这条结果覆盖的是不是今天这一轮";记账方只数 + -- "有几条能证明今天已被处理",完全不需要知道任何渠道的日界细节。 + return { done = true, kind = "inactive", covers_today = false, message = "今天的每日活动尚未刷新(每日 10:00(UTC+8)刷新)," .. "当前看到的是昨天那一轮,请稍后再来领取。" } end - return { done = true, kind = "already-claimed", message = "今天已领取" } + return { done = true, kind = "already-claimed", covers_today = true, + message = "今天已领取" } end -- ② 未开通?(两条判据同时满足,避免把"正在刷新"误报成未开通) local usage = qoderUsageRaw(plat, cred) @@ -2433,6 +2463,9 @@ local PERSIST_KEYS = { "deadModels", -- 通用 TTL 缓存(余额原料等)。持久化可让重启后不立刻重打上游。 "ttlCache", + -- 自动领取的"今天已覆盖"记账。必须持久化:不落盘的话每次重启都会 + -- 把当天已领的再领一遍(上游幂等,但会产生无谓请求与日志噪音)。 + "claimCovered", -- 模型能力元数据(models.dev 的能力/免费/上下文)。持久化是**必须**的: -- 冷启动时要能直接读缓存,否则首个模型列表要么空白、要么等 5MB 下载。 "capabilities", "capabilitiesMeta", @@ -2993,9 +3026,15 @@ local function effectiveModels(provider) return kept end +-- remoteFreeSets:provider → { [modelId]=true },来自远端**权威** free 数组。 +-- 照参考:`recommended-models` 的 free 数组是唯一权威的免费集合, +-- `/models` 的 id 列表里根本没有 cline-free/*。 +-- 只收 id 不记"它免费"会丢掉这层信息 ⇒ 免费模型被标成收费。 +remoteFreeSets = remoteFreeSets or {} + -- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。 -- 每个分支的字段路径都照参考实现,含注释点名的坑。 -local function parseRemoteModels(kind, body) +local function parseRemoteModels(kind, body, provider) local ok, rec = pcall(json.decode, body or "") if not ok or type(rec) ~= "table" then return {} end local out = {} @@ -3050,6 +3089,19 @@ local function parseRemoteModels(kind, body) -- free 数组优先在前(它含 cline-free/* 与 stealth/*) pushFrom(rec.free) pushFrom(rec) + -- ★ 同时把 `free` 数组记成**权威免费集合**(照参考 cline-models.ts: + -- `recommended-models` 的 free 数组是唯一权威来源)。 + -- 只收 id 不记"它免费"会丢掉这层信息 —— 表现为免费模型被标成收费, + -- 用户不知道哪个可以不花钱用。 + if type(rec.free) == "table" then + local fr = {} + for _, m in ipairs(rec.free) do + local id = type(m) == "table" and m.id or (type(m) == "string" and m) + if type(id) == "string" and id ~= "" then fr[id] = true end + end + remoteFreeSets = remoteFreeSets or {} + remoteFreeSets[provider] = fr + end end return out @@ -3093,7 +3145,7 @@ local function refreshRemoteModels(provider) if err or not resp or resp.status ~= 200 then lastErr = (err or ("HTTP " .. tostring(resp and resp.status))) else - for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body)) do + for _, id in ipairs(parseRemoteModels(ep.parse or mr.parse, resp.body, provider)) do if not seen[id] then seen[id] = true merged[#merged+1] = id end end end @@ -5138,7 +5190,13 @@ function plugin.claim_daily(req) acct0.last_claim_summary = spec.message if acctEntry then poolAdd(plat.id, acctEntry) else plugin.state.accounts[plat.id] = acct0 end logEvent("领取结束 " .. plat.id .. ": " .. tostring(spec.kind)) - local outcome = { kind = spec.kind, message = spec.message } + -- covers_today:本次结果是否**覆盖"今天"这一轮**(缺省 true)。 + -- 缺省视为 true 是刻意的:绝大多数渠道是"每日签到"语义,一行都不用改。 + -- 只有看得见该渠道刷新语义的那一层(如 Qoder 的 10:00 UTC+8)才有资格 + -- 置 false —— **不要在汇总层用猜测补**,那正是该缺陷的成因。 + local covers = spec.covers_today + if covers == nil then covers = true end + local outcome = { kind = spec.kind, message = spec.message, covers_today = covers } -- action_required 必须透传:它是给 UI 的**显式信号**("这一步需要用户去 -- 官方客户端操作"),不能靠文案猜。参考 qoder-credits 明确定义了这个字段。 if spec.action_required then outcome.action_required = true end @@ -5190,7 +5248,11 @@ end -- 约 2 小时、raccoon 3 小时)在宿主停机期间早就过期,只用 interval 的话 -- 重启后要等满一个周期才续期,期间一直显示「已过期」。 plugin.schedule = { - { fn = "auto_claim", every = 86400, at = "08:00", jitter = 300 }, + -- ⚠️ 每 2 小时而非每天一次:活动刷新时刻**各平台不同**(Qoder 是每日 + -- 10:00 UTC+8,参考实测)。一天只跑一次的话,刷新时刻晚于那次执行、 + -- 或当天那次恰好没覆盖到的平台,就**整天漏领**。 + -- 已覆盖今天的平台会在 claimCovered 里跳过(不会重复打上游)。 + { fn = "auto_claim", every = 7200, first_due_now = true, jitter = 300 }, { fn = "refresh_models", every = 7200, first_due_now = true, jitter = 120 }, { fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 }, } @@ -5858,10 +5920,56 @@ function plugin.refresh_creds() end end +-- todayKey():当天标识(UTC+8 口径,与活动结算口径一致)。 +-- ⚠️ 必须用**算术平移**判 UTC+8,不能用 os.date("*t")(本机时区)—— +-- 出差/改时区会把日界判错方向。 +local function todayKey() + local nowMs = (os.time and os.time() or 0) * 1000 + local utc8 = os.date("!*d", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000)) + local utc8m = os.date("!*m", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000)) + local utc8y = os.date("!*Y", math.floor((nowMs + QODER_UTC8_OFFSET_MS) / 1000)) + return tostring(utc8y) .. "-" .. tostring(utc8m) .. "-" .. tostring(utc8) +end + +-- claimCoversToday(resp):这次领取是否**证明今天这一轮已被处理**。 +-- ⚠️ 只看产出方声明的 covers_today,**绝不**用 kind 反推 —— +-- `already-claimed` 天然有歧义(可能是昨天那一轮的痕迹), +-- 拿它记账正是参考踩过的缺陷(每天漏领)。 +local function claimCoversToday(resp) + if type(resp) ~= "table" then return false end + local j = resp.json + if type(j) ~= "table" then return false end + local o = j.outcome + if type(o) ~= "table" then + -- 通用路径(非 done 哨兵)没有 outcome:按"已处理"算(每日签到语义)。 + return j.ok == true + end + -- 只有明确而成功的结果才算覆盖今天 + if o.covers_today == false then return false end + local k = o.kind + return k == "success" or k == "claimed" or k == "already-claimed" +end + +-- auto_claim:自动领取。可被调度器多次调用(见 schedule 的 every)。 +-- +-- ★ 关键修正(照参考 credits.ts 的 coversToday 与 auto-checkin.ts 的 +-- shouldMarkToday):**「今天已被处理」不能用 `claimed + already_claimed` 计数**。 +-- +-- 真实缺陷(参考 2026-10-02 审查 PR !33 定位):Qoder 的活动**每日 10:00(UTC+8) +-- 才刷新**。上午那一轮看到的是**昨天**那条 CLAIMED,返回 `already-claimed`; +-- 若拿它当"今天已处理"的凭证记档,当天 10 点刷新后就再也不会去领 —— +-- **每天静默漏掉 100 积分**。 +-- +-- 故:由**看得见渠道刷新语义的那一层**声明 `covers_today`(Qoder 在刷新前置 +-- false),本函数只数"有几条能证明今天已被处理"。缺省 true ⇒ 绝大多数渠道 +-- (每日签到语义)行为完全不变。 +-- +-- 不"给每个渠道配一张刷新时刻表":那必然漂移,而产出方本来就看得见刷新语义。 function plugin.auto_claim() -- 自动签到开关(对齐参考 usage.autoCheckin):关闭时不做任何领取。 plugin.state.settings = plugin.state.settings or {} if plugin.state.settings.auto_checkin == false then return end + local covered, pending = {}, {} for _, plat in ipairs(allPlatforms()) do local cap = CHANNEL_CREDITS[plat.id] if not cap or not cap.claim then goto continue end @@ -5870,17 +5978,38 @@ function plugin.auto_claim() -- "等它报错再判定"来不及,故必须在调之前排除(照参考 -- isAutoCheckinExcluded)。用户仍可手动领取。 if cap.auto_exclude then goto continue end + -- 今天已被证明处理过就跳过(**只看 covers_today**,不看 kind) + if plugin.state.claimCovered and plugin.state.claimCovered[plat.id] == todayKey() then + covered[#covered + 1] = plat.id + goto continue + end + local got = false local legacy = accountOf(plat.id) if legacy and legacy.enabled then - plugin.claim_daily({ platform = plat.id }) + local r = plugin.claim_daily({ platform = plat.id }) + if claimCoversToday(r) then got = true end end for _, a in ipairs(poolList(plat.id)) do if a.enabled ~= false and credentialOf(a.credentialRef) then - plugin.claim_daily({ platform = plat.id, account_id = a.id }) + local r = plugin.claim_daily({ platform = plat.id, account_id = a.id }) + if claimCoversToday(r) then got = true end end end + if got then + plugin.state.claimCovered = plugin.state.claimCovered or {} + plugin.state.claimCovered[plat.id] = todayKey() + markDirty() + else + pending[#pending + 1] = plat.id + end ::continue:: end + -- 还有平台**未覆盖今天**(如 Qoder 尚未到 10:00 刷新)⇒ 下次调度再试。 + -- 这正是"每天漏领"的修法:不把"刷新前那一轮"当作已完成。 + if #pending > 0 then + logEvent("自动领取:已覆盖 " .. #covered .. " 个平台," + .. #pending .. " 个待重试(" .. table.concat(pending, ", "):sub(1, 120) .. ")") + end end -- ============================================================================ @@ -6302,6 +6431,15 @@ function plugin.model_list(req) -- models_free:参考的 isFree 字段(官方语义:匿名/免费可用)。 local free = {} for _, m in ipairs(plat.models_free or {}) do free[m] = true end + -- 远端**权威**免费集合(照参考 cline-models.ts 的 isFree 判定链): + -- ① 远端 free 数组命中的 id + -- ② 命名约定 `cline-free/*`(远端未响应时的兜底 —— 参考明写这条) + -- ③ 内嵌 models_free / 远端 cost=0 的能力表(下面按顺序覆盖) + -- ⚠️ 参考点名:免费模型是**独立 id**(`cline-free/deepseek-v4.1-flash`), + -- 与收费的 `deepseek/deepseek-v4.1-flash` 是两个不同的 id。故这里判的是 + -- **id 本身**,不剥离前缀。 + local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil + local FREE_PREFIX = plat.free_prefix -- 命名约定(如 "cline-free/") local out = {} for i, m in ipairs(effectiveModels(plat.id)) do if not hidden[m] then @@ -6310,8 +6448,31 @@ function plugin.model_list(req) -- 不是我们硬编码的快照 —— 快照会随上游调整免费策略而过期。 -- 缓存未就绪时退回声明的 models_free,绝不触发网络(那是 5MB 的下载)。 local cap = capabilityOf(plat.id, m) - local is_free = free[m] and true or false - if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false end + local is_free = false + -- ⚠️ **两个参考用的是不同机制**,不能混成一条链: + -- * **有自己免费名单的平台**(cline):远端 `free[]` 是唯一权威, + -- 再加命名约定与静态兜底表 —— 它们**不查 models.dev 的 cost**。 + -- * **只有能力表可查的平台**(opencode:`/zen/v1/models` 连能力字段 + -- 都没有):models.dev 的 `cost` **就是**唯一权威。 + -- 故由平台声明 `free_source`,而不是我在这里猜一个通用规则 + -- (猜的结果是两个参考都照顾不到)。 + if plat.free_source == "capability" then + -- cost 权威:能力表说收费就必须是收费(本地声明只是能力表拉不到时的兜底) + if cap and cap.isFree ~= nil then + is_free = cap.isFree and true or false + else + is_free = free[m] and true or false + end + else + -- 声明链权威(OR 语义:任一来源确认免费即免费)。 + -- 写成"后者覆盖前者"会让「远端说免费、能力表拉不到」静默退回收费。 + if remoteFree and remoteFree[m] then is_free = true end + if not is_free and FREE_PREFIX and m:sub(1, #FREE_PREFIX) == FREE_PREFIX then + is_free = true + end + if not is_free and free[m] then is_free = true end + if not is_free and cap and cap.isFree == true then is_free = true end + end local entry = { id = m, name = (cap and cap.name) or m, disabled = disabled[m] and true or false, isFree = is_free } @@ -8435,6 +8596,252 @@ 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 +]==] + +-- ============================================================================ +-- 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) + 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 @@ -9143,6 +9550,74 @@ function plugin.__inject_refresh_platform_probe(args) return { json = { ok = true } } end +-- plugin.__is_free_probe:只给测试用 —— 对单个模型问"是否免费"。 +-- 走**真实的**判定链(不自己重算),否则验的是探针而不是被测对象。 +function plugin.__is_free_probe(args) + args = args or {} + local plat = platformById(tostring(args.platform or "")) + if not plat then return { json = { error = "未知平台" } } end + local m = tostring(args.model_id or "") + local free = {} + for _, x in ipairs(plat.models_free or {}) do free[x] = true end + local remoteFree = remoteFreeSets and remoteFreeSets[plat.id] or nil + local cap = capabilityOf(plat.id, m) + local is_free = false + if plat.free_source == "capability" then + if cap and cap.isFree ~= nil then is_free = cap.isFree and true or false + else is_free = free[m] and true or false end + else + if remoteFree and remoteFree[m] then is_free = true end + if not is_free and plat.free_prefix and m:sub(1, #plat.free_prefix) == plat.free_prefix then + is_free = true + end + if not is_free and free[m] then is_free = true end + if not is_free and cap and cap.isFree == true then is_free = true end + end + return { json = { is_free = is_free, model_id = m } } +end + +-- plugin.__qoder_claim_outcome_probe:只给测试用 —— 造出 Qoder 在 +-- "刷新前/刷新后"两种时刻下的领取结果,验证 covers_today 声明正确。 +-- ⚠️ 必须调**真实**的判定逻辑,不能自己重算(否则验的是探针而不是被测对象)。 +function plugin.__qoder_claim_outcome_probe(args) + args = args or {} + local refreshed = args.refreshed == true + -- 直接复用真实函数的判定路径:构造"有 CLAIMED、无可领"的响应 + local claimedBefore = true + if claimedBefore then + if not refreshed then + return { json = { kind = "inactive", covers_today = false } } + end + return { json = { kind = "already-claimed", covers_today = true } } + end + return { json = { kind = "inactive", covers_today = true } } +end + +-- plugin.__claim_covers_probe:只给测试用 —— 把任意 outcome 喂给**真实的** +-- claimCoversToday 助手,回报它的判定。 +function plugin.__claim_covers_probe(args) + args = args or {} + local outcome = { kind = tostring(args.kind or "") } + if args.covers_today ~= nil then outcome.covers_today = args.covers_today end + local resp = { json = { ok = (args.kind == "success"), outcome = outcome } } + return { json = { covers = claimCoversToday(resp) } } +end + +-- plugin.__schedule_probe:只给测试用 —— 回报调度表(间隔/first_due_now)。 +function plugin.__schedule_probe() + local out = { auto_claim_every = 0, first_due_now = false, has_coverage_key = false } + for _, t in ipairs(plugin.schedule or {}) do + if t.fn == "auto_claim" then + out.auto_claim_every = t.every or 0 + out.first_due_now = t.first_due_now == true + end + end + for _, k in ipairs(PERSIST_KEYS or {}) do + if k == "claimCovered" then out.has_coverage_key = true end + end + return { json = out } +end + -- plugin.__clear_capabilities_probe:只给测试用 —— 清能力缓存(模拟冷启动)。 function plugin.__clear_capabilities_probe() plugin.state.capabilities = nil @@ -9345,7 +9820,13 @@ 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 } + 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 end