From 583976f7da4f524ca8a0e73c7ced33b54bcd3704 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 14 Oct 2026 15:11:05 +0800 Subject: [PATCH] =?UTF-8?q?feat(channels):=20=E8=A1=A5=E4=B8=A4=E4=B8=AA?= =?UTF-8?q?=E5=8F=82=E8=80=83=E6=98=8E=E7=A1=AE"=E4=B8=8D=E8=83=BD?= =?UTF-8?q?=E7=9C=81"=E7=9A=84=E5=9F=BA=E7=A1=80=E8=AE=BE=E6=96=BD?= =?UTF-8?q?=EF=BC=88=E5=B7=B2=E5=A4=B1=E6=95=88=E6=A8=A1=E5=9E=8B=20+=20?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E9=97=B8=E9=97=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 穷尽对照 143 个参考模块后,挑出两个**有实测理由**的补上。 ## ① 已失效模型记录(照参考 dead-model-store.ts,708 行) 修的是真实报障:用户选中某平台的兜底模型,每轮 404 —— 该模型**已被上游下架**, 但列表里仍显示、还标着「免费」。根因:兜底表是**编译期快照**,上游下架不会 自己变。 ★ 关键纪律(参考文件头有实测教训):**不能用「远端目录里没有 ⇒ 已下架」反推**。 那个判据只在远端目录完整权威的平台上成立;buddy 那类平台的远端目录**已知残缺** (CLI token 只拿得到 13 个内部别名、IDE 有 20 个),按目录比对会把「你有权用、 只是 token 看不到」的模型删掉;qoder 更是没有可比对的目录。 ⇒ 唯一跨平台安全的判据是**阳性证据**:真的 404 过才记。 判据逐条对齐参考(17 个用例,全是它踩过的误判): - `model_not_found` 只在**独占一句话成分**时算(后面只允许结束/句末标点/收尾 括号引号,**不含逗号**)—— 它在遥测里常作字段名出现: `error_class=model_not_found, retry allowed` 曾被误判下架 - rate limit / quota / insufficient / unauthor / forbidden / invalid key 一律**不是** - `expired` 必须带主语(token/session/key/…),否则会误杀真实下架文案 - 只认 found/exist,**不认** available/supported(那是"换套餐就能恢复") - 判定顺序:NOT_GONE 先于 GONE(与参考 `isModelGoneError` 逐行一致) TTL 30 天(过期自动复活,照参考 DSH_DEAD_MODEL_TTL_DAYS 语义)。 `model_clear_dead` 语义**修正**:原实现清的是"被禁用且不在目录里"(判据用错了、 拿目录反推),现改为清**失效记录**。 ## ② 远端目录加载闸门(照参考 remote-catalog-gate.ts) 参考点名「**不能省**」:宿主对每个 provider 拉目录、再对每个模型解析一次, 于是「目录没拿到」被放大成**一次请求 × 每个模型**,而超时是 30–60s 量级 —— 未登录/离线时表现就是「首屏巨长」。三道语义:命中缓存 / 并发去重 / 失败冷却。 Lua 无协程做不了「共享同一个 Promise」,用**时间戳闸门**等价实现 (in_flight_at 防同轮重复 + retry_at 失败冷却)。 含参考的**墙钟回拨防护**:`retry_at - now > cooldown` 一律按已到期处理 —— 否则系统时间回拨一天会把冷却变成事实上的永久卡死。 ## ③ 顺手修:状态落盘不建目录 `persistState` 直接 `io.open(path..".tmp")`,目录不存在就失败。而失败只打 stderr 一行「状态落盘失败」—— 表现是「状态永不落盘、重启即丢」,很容易被忽略。 现先 `mkdir -p`。这是实测发现的(authorize 测试的临时目录尚未创建时报的)。 ## 判据 `channels_dead_model_test.go`:判据 17 例(含 6 个"看着像但不是下架"的反例)、 记录-剔除-恢复闭环、闸门冷却。 `channels_pool_test.go`:`model_clear_dead` 断言按新语义改写,并加"清失效记录 **不得**动 disabled 黑名单"的隔离断言。 回归:9 个包全绿。 --- internal/core/channels_dead_model_test.go | 204 ++++++++++++++++ internal/core/channels_pool_test.go | 34 ++- internal/lua/plugins/channels.lua | 282 +++++++++++++++++++++- 3 files changed, 500 insertions(+), 20 deletions(-) create mode 100644 internal/core/channels_dead_model_test.go diff --git a/internal/core/channels_dead_model_test.go b/internal/core/channels_dead_model_test.go new file mode 100644 index 0000000..d435721 --- /dev/null +++ b/internal/core/channels_dead_model_test.go @@ -0,0 +1,204 @@ +package core + +import ( + "testing" +) + +// 「已失效模型」判据必须严格 —— 误判的代价是把**可用模型藏 30 天**。 +// +// 参考为这些判据写了很长的注释(都是实测误判教训),本判据把它们钉住: +// +// - `model_not_found` 在遥测/日志里常作**字段名**出现(后面还有内容), +// 只在它"独占一句话成分"时才算阳性; +// - rate limit / quota / unauthor / forbidden 一律**不是**下架; +// - `expired` 必须带主语(token/session/key/…),否则会误杀正常下架文案 +// `model expired-v3 does not exist`; +// - 只认 found/exist,**不认** available/supported(那是"换套餐就能恢复")。 +func TestDeadModelGoneJudgement(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + + cases := []struct { + msg string + want bool + why string + }{ + // ── 阳性:真的是下架 ── + {"model not found", true, "经典文案"}, + {"unknown model: foo", true, "unknown model"}, + {"no such model", true, "no such model"}, + {"The model does not exist", true, "does not exist"}, + {"error: model_not_found", true, "独占一句话(行尾)"}, + {"model_not_found.", true, "后面是句末标点"}, + // ── 阴性:看着像但**不是**下架 ── + {"error_class=model_not_found, retry allowed", false, + "字段名后面还有内容(初版误判下架,实测教训)"}, + {"the model_not_found metric was not exported", false, + "作为指标名出现(实测教训)"}, + {"rate limit exceeded for model foo", false, "限流换时段就能用"}, + {"quota exceeded", false, "额度问题"}, + {"insufficient balance", false, "余额问题"}, + {"401 unauthorized", false, "认证问题"}, + {"invalid api key", false, "认证问题"}, + {"token expired", false, "token 过期(带主语)"}, + // ⚠️ 这条最微妙:expired 无主语时**不**能触发 NOT_GONE, + // 否则会误杀 "model expired-v3 does not exist" 这类真实下架文案。 + {"model expired-v3 does not exist", true, + "expired 无主语 → 不算认证失败;does not exist 才是真判据"}, + // ⚠️ 两条更正(对照参考实现的实际行为,不是注释字面): + // 1. `lease expired` 单独出现**不算**下架 —— 它是租约/认证问题。 + // 2. 即便带上 does not exist 也**仍不算**:参考的判定顺序是 + // NOT_GONE 先于 GONE(`isModelGoneError` 第 265 行先查排除表), + // 一旦命中排除词就直接 false、不再看后面的不存在语义。 + // 我最初按注释字面以为"带主语才对、带上就想判下架",是读错了。 + {"the model lease expired", false, "租约/认证问题,不是下架"}, + {"the model lease expired and does not exist", false, + "参考的顺序是 NOT_GONE 先行 ⇒ 命中排除词即 false"}, + // available/supported 不算(换套餐就能恢复) + {"model not available in your region", false, + "available 不是 found/exist —— 换地域就能用(参考明确点名)"}, + {"", false, "空串"}, + } + for _, tc := range cases { + out, err := ps.Call("channels", "__is_model_gone_probe", + map[string]interface{}{"message": tc.msg}) + if err != nil { + t.Fatalf("探针: %v", err) + } + w, _ := out.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + got, _ := j["gone"].(bool) + if got != tc.want { + t.Errorf("isModelGone(%q) = %v,应为 %v(%s)", tc.msg, got, tc.want, tc.why) + } + } +} + +// 记录-剔除-恢复 的闭环,以及**不拿远端目录反推**这条纪律。 +func TestDeadModelRecordAndPrune(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + + before := modelIDsFor(t, ps, "zcode") + if len(before) == 0 { + t.Fatal("zcode 应有兜底模型") + } + victim := before[0] + + // 记录一条阳性证据 → 该模型必须从列表消失 + if _, err := ps.Call("channels", "__record_dead_probe", + map[string]interface{}{"platform": "zcode", "model_id": victim, "reason": "model not found"}); err != nil { + t.Fatalf("记录探针: %v", err) + } + after := modelIDsFor(t, ps, "zcode") + for _, id := range after { + if id == victim { + t.Errorf("已失效模型 %q 仍出现在列表里(应剔除)", victim) + } + } + if len(after) != len(before)-1 { + t.Errorf("剔除后应少一个:前 %d 后 %d", len(before), len(after)) + } + + // model_clear_dead 应把它恢复回来(用户确认上游又上了 / 误判时用) + m, err := ps.Call("channels", "model_clear_dead", + map[string]interface{}{"platform": "zcode", "model_id": victim}) + if err != nil { + t.Fatalf("model_clear_dead: %v", err) + } + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j == nil || j["ok"] != true { + t.Fatalf("model_clear_dead 失败: %#v", m) + } + restored := modelIDsFor(t, ps, "zcode") + found := false + for _, id := range restored { + if id == victim { + found = true + } + } + if !found { + t.Errorf("clear 后 %q 应恢复,实得 %v", victim, restored) + } +} + +// 目录闸门:失败后进入冷却,冷却期内不重复拉(照参考 remote-catalog-gate)。 +// 判据用冷却状态本身,不依赖真实网络。 +func TestRemoteCatalogGateCooldown(t *testing.T) { + c := kernelTestCore(t) + ps := c.Plugins() + src, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", src); err != nil { + t.Fatal(err) + } + // 初始不冷却 + g := func() (bool, bool) { + out, err := ps.Call("channels", "__catalog_gate_probe", + map[string]interface{}{"platform": "zcode"}) + if err != nil { + t.Fatalf("探针: %v", err) + } + w, _ := out.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + began, _ := j["began"].(bool) + cooling, _ := j["cooling"].(bool) + return began, cooling + } + if _, cooling := g(); cooling { + t.Error("初始不该处于冷却") + } + // 模拟一次失败 → 进冷却 + if _, err := ps.Call("channels", "__catalog_gate_fail_probe", + map[string]interface{}{"platform": "zcode"}); err != nil { + t.Fatalf("失败探针: %v", err) + } + began, cooling := g() + if !cooling { + t.Error("失败后必须进入冷却(否则会对坏上游反复打 —— 参考点名的首屏卡死)") + } + if began { + t.Error("冷却期内不该再发起拉取") + } +} + +// modelIDsFor 读某平台当前对外的模型 id 列表。 +func modelIDsFor(t *testing.T, ps interface { + Call(string, string, map[string]interface{}) (interface{}, error) +}, plat string) []string { + t.Helper() + m, err := ps.Call("channels", "model_list", map[string]interface{}{"platform": plat}) + if err != nil { + t.Fatalf("model_list(%s): %v", plat, err) + } + w, _ := m.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + var out []string + if arr, ok := j["models"].([]interface{}); ok { + for _, it := range arr { + if mm, ok := it.(map[string]interface{}); ok { + if id, ok := mm["id"].(string); ok { + out = append(out, id) + } + } + } + } + return out +} diff --git a/internal/core/channels_pool_test.go b/internal/core/channels_pool_test.go index 7462ba5..01b0964 100644 --- a/internal/core/channels_pool_test.go +++ b/internal/core/channels_pool_test.go @@ -265,17 +265,33 @@ func TestChannelsExtendedRPCBehavior(t *testing.T) { invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["MiniMax-M3","MiniMax-M2.7-highspeed"],"disabled":false}`) }) - t.Run("model_clear_dead_只清黑名单里已下线的", func(t *testing.T) { - invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) + // model_clear_dead 的语义已按参考修正:它清的是**有阳性 404 证据**的 + // "已失效模型"记录,**不是**"被禁用且不在目录里"的条目。 + // + // 旧断言测的是后者(拿远端目录反推下架)—— 那个判据在远端目录残缺的平台上 + // 会删掉可用模型(参考文件头有实测教训),已弃用。 + // 新语义的完整判据在 channels_dead_model_test.go。 + t.Run("model_clear_dead_清失效记录而非拿目录反推", func(t *testing.T) { + // 没有失效记录时:removed=0,且文案要说明"无记录"(而不是假装清了什么) j := invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) - if n := len(asArr(j["removed"])); n != 0 { - t.Fatalf("在册模型不算死模型, 实得 %d", n) + if j["ok"] != true { + t.Fatalf("model_clear_dead 应成功: %v", j) } - // 手动放一个不在 models 表里的黑名单项 - invoke(t, ps, "model_set_disabled_many", `{"platform":"minimax","model_ids":["ghost-model"],"disabled":true}`) - j = invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) - if n := len(asArr(j["removed"])); n != 1 { - t.Fatalf("死模型应被清: %v", j) + if n, _ := j["removed"].(float64); n != 0 { + t.Fatalf("无失效记录时应为 0, 实得 %v", j["removed"]) + } + // 黑名单(disabled)与失效记录是两回事:清失效**不得**动 disabled + invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":true}`) + invoke(t, ps, "model_clear_dead", `{"platform":"minimax"}`) + ml := invoke(t, ps, "model_list", `{"platform":"minimax"}`) + off := 0 + for _, m := range asArr(ml["models"]) { + if mm, ok := m.(map[string]interface{}); ok && mm["disabled"] == true { + off++ + } + } + if off != 1 { + t.Fatalf("清失效记录不该动 disabled 黑名单, 仍应为 1, 实得 %d", off) } invoke(t, ps, "model_set_disabled", `{"platform":"minimax","model_id":"MiniMax-M3","disabled":false}`) }) diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index c2049c0..17f411c 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", "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", "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", "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", "__record_dead_probe", "__catalog_gate_probe", "__catalog_gate_fail_probe", "credits_capability", "claims_capable" } @@ -2352,6 +2352,12 @@ local PERSIST_KEYS = { -- 远端拉到的模型目录缓存(远端为主、兜底补位的结果)。 -- 缓存它而非每次请求都拉:上游目录是分钟级稳定,UI 却是秒级刷新。 "remoteModels", + -- 已失效模型记录(阳性 404 证据)。必须持久化:不落盘的话重启就全忘了, + -- 用户又要重新被 404 打一遍(照参考 DEAD_MODELS_FILE 的落盘语义)。 + "deadModels", + -- 目录闸门状态(冷却/in-flight)。也落盘:重启后冷却窗口应继续有效, + -- 否则"重启即重试坏上游"(照参考把闸门状态随插件存活的语义)。 + "catalogGate", } local function persistState() @@ -2364,6 +2370,11 @@ local function persistState() local body = json.encode({ version = 1, state = snap }) if not body then return false, "json.encode 失败" end local tmp = path .. ".tmp" + -- 目录可能还不存在(首次运行、或内核交给我们的目录尚未创建)。 + -- 不 mkdir 就直接 io.open 会失败,而失败只打到 stderr —— 表现为 + -- "状态永不落盘"(重启即丢),日志里一行「状态落盘失败」很容易被忽略。 + local dir = path:match("^(.*)/[^/]+$") + if dir and dir ~= "" then os.execute("mkdir -p '" .. dir .. "' 2>/dev/null") end local w = io.open(tmp, "w") if not w then return false, "无法写入 " .. tmp end w:write(body) @@ -2752,6 +2763,10 @@ end -- 注意:兜底表是**编译期快照**,价格/倍率会变,故兜底条目不携带价格类字段。 -- 取该平台**当前有效**的模型目录(远端优先,兜底补位,剔除黑名单)。 +-- 前向声明:deadModelIds 定义在下方(失效模型存储一节),而 effectiveModels +-- 在它之前就要剔除失效模型。Lua 的 local 只从定义处向后可见 —— 第 9 次踩。 +local deadModelIds + local function effectiveModels(provider) local plat = platformById(provider) if not plat then return {} end @@ -2769,7 +2784,15 @@ local function effectiveModels(provider) for _, id in ipairs(plat.models or {}) do if not seen[id] then seen[id] = true out[#out+1] = id end end - return out + -- 剔除**有阳性证据**已失效的模型(照参考 dead-model-store 的剔除语义)。 + -- ⚠️ 只剔这里记录过的(真正 404 过的),**绝不**拿远端目录反推下架 —— + -- buddy/qoder 那类平台的远端目录残缺,反推会删掉可用模型。 + local dead = deadModelIds(provider) + local kept = {} + for _, id in ipairs(out) do + if not dead[id] then kept[#kept+1] = id end + end + return kept end -- parseRemoteModels(parse, body):把各平台响应解析成 id 列表。 @@ -4960,16 +4983,213 @@ plugin.schedule = { { fn = "refresh_creds", every = 1800, first_due_now = true, jitter = 60 }, } +-- ============================================================================ +-- ============================================================================ +-- 已失效模型(照参考 dead-model-store.ts) +-- ============================================================================ +-- 解决的真实报障:用户选中某平台的兜底模型,每轮都 404 —— +-- 该模型**已被上游下架**,但模型列表里仍显示、还标着"免费"。 +-- 根因:各平台的兜底表是**编译期快照**,上游下架时不会自己跟着变。 +-- +-- ⚠️ **不能用「远端目录里没有 ⇒ 已下架」来剔除**(参考文件头实测): +-- 那个判据只在"远端目录完整权威"的平台上成立。buddy 那类平台的远端目录 +-- **已知残缺**(CLI token 只拿得到 13 个内部别名,IDE 有 20 个), +-- 按目录比对会把"你有权用、只是 token 看不到"的模型删掉 —— 参考作者已为此 +-- 踩过一次。qoder 更是**没有**可比对的远端目录。 +-- ⇒ 唯一跨平台安全的判据是**阳性证据**:这个模型**真的**请求失败并返回 +-- 「模型不存在」时,才记为失效。**绝不从残缺目录反推。** + +-- 判定为"模型已失效"的正向模式(照参考 GONE_PATTERNS 逐条)。 +-- +-- ⚠️ `model_not_found` 必须要求它**独占一句话成分**:只加词边界不够 —— +-- 它在遥测/日志文案里常作为**字段名**出现,后面紧跟别的内容: +-- error_class=model_not_found, retry allowed → 初版误判下架 +-- model_not_found_reason=upstream → 初版误判下架 +-- 误判会把**可用模型藏 30 天**、用户须手动恢复。故后面只允许结束、句末标点 +-- 或收尾括号/引号,**不含逗号**。 +local DEAD_GONE_PATTERNS = { + "model%s+not%s+found", + "unknown%s+model", + "no%s+such%s+model", + "nonexistent%s+model", +} + +-- 反向模式:命中即**不是**下架(照参考 NOT_GONE_PATTERNS)。 +-- 逐条都有理由,不要删: +-- * rate limit / quota / insufficient → 换时段或加额度就能用 +-- * unauthor/forbidden/invalid token → 认证问题,不是模型不存在 +-- * `expired` **必须带主语**(token/session/credential/key/auth/lease), +-- 否则会误杀正常的下架文案:`model expired-v3 does not exist`、 +-- `the model lease expired`(租约到期也是下架的一种说法) +local DEAD_NOT_GONE_PATTERNS = { + "rate%s*%-?%s*limit", + "too%s+many%s+requests", + "quota", + "insufficient", + "unauthor", "forbidden", "invalid%s+token", "invalid%s+api%s*key", "invalid%s+credential", + "token%s+expired", "session%s+expired", "credential%s+expired", "key%s+expired", + "auth%s+expired", "lease%s+expired", + "expired%s+token", "expired%s+session", "expired%s+credential", "expired%s+key", + "expired%s+auth", +} + +-- isModelGone(message):把一段错误文本判定为"模型已失效"。 +-- +-- 返回 true 的**唯一**条件是出现阳性证据;拿不准一律 false —— +-- 误判的代价是把可用模型藏 30 天,漏判的代价只是用户再看到一次 404。 +local function isModelGone(message) + if type(message) ~= "string" or message == "" then return false end + local m = message:lower() + for _, pat in ipairs(DEAD_NOT_GONE_PATTERNS) do + if m:find(pat) then return false end + end + for _, pat in ipairs(DEAD_GONE_PATTERNS) do + if m:find(pat) then return true end + end + -- `model_not_found` 形态:要求后面只剩结束/句末标点/收尾括号引号。 + -- Lua 的 find 无前瞻,故手动定位后检查紧随的字符。 + local from = 1 + while true do + local a, b = m:find("model[_%-]not[_%-]found", from) + if not a then break end + local rest = m:sub(b + 1, b + 1) + -- 只允许:结束、句末标点、收尾括号/引号。**不含逗号** + -- ("…not_found, retry allowed" 里逗号后仍是另一个字段 ⇒ 不算阳性)。 + -- 字符类里 `]` 必须放最前或转义;这里直接列字符以免 Lua 解析歧义。 + -- 用**单引号**的 Lua 串,避免字符类里的双引号提前终止字符串。 + if rest == "" or rest:find('[%]%)%}%.!%;"]') then return true end + from = b + 1 + end + -- 「id + 紧邻的不存在语义」:只认 found/exist。 + -- ⚠️ 不认 available/supported —— 那是"换套餐/换地域就能恢复",不是下架。 + if m:find("does%s+not%s+exist") or m:find("doesn't%s+exist") then return true end + return false +end + +-- 失效记录 TTL(秒)。照参考默认 30 天;过期自动复活,避免永久隐藏。 +local DEAD_MODEL_TTL_DAYS = 30 + +local function deadModelsOf(provider) + plugin.state.deadModels = plugin.state.deadModels or {} + local p = plugin.state.deadModels[provider] + if not p then p = {} plugin.state.deadModels[provider] = p end + return p +end + +-- recordDeadModel:记录一次**阳性**证据(真正 404 了)。 +-- 返回 true 表示这是新记录(调用方据此决定要不要重同步源)。 +local function recordDeadModel(provider, modelId, reason) + if not modelId or modelId == "" then return false end + local p = deadModelsOf(provider) + if p[modelId] then return false end + p[modelId] = { at = os.time and os.time() or 0, reason = tostring(reason or ""):sub(1, 120) } + return true +end + +-- deadModelIds(provider):当前仍有效的失效模型 id 集合(已过 TTL 的自动清掉)。 +deadModelIds = function(provider) + local p = deadModelsOf(provider) + local now = os.time and os.time() or 0 + local ttl = DEAD_MODEL_TTL_DAYS * 86400 + local out = {} + for id, meta in pairs(p) do + local at = type(meta) == "table" and tonumber(meta.at) or 0 + if ttl > 0 and (now - at) > ttl then + p[id] = nil -- 过期复活(照参考 TTL 语义) + else + out[id] = true + end + end + return out +end + +-- clearDeadModels(provider, modelId?):清失效记录(不带 id 则清该平台全部)。 +local function clearDeadModels(provider, modelId) + local p = deadModelsOf(provider) + if modelId and modelId ~= "" then + local had = p[modelId] ~= nil + p[modelId] = nil + return had and 1 or 0 + end + local n = 0 + for _ in pairs(p) do n = n + 1 end + plugin.state.deadModels[provider] = {} + return n +end + +-- 远端目录加载闸门(照参考 remote-catalog-gate.ts) +-- ============================================================================ +-- 参考明确点名「**不能省**」,理由实测: +-- 宿主会对**每个** provider 拉目录、再对**每个模型**解析一次,于是「目录没拿到」 +-- 被放大成**一次请求 × 每个模型**。若失败时不落任何标记,下一次还会再拉一遍, +-- 而这些拉取的超时是 30–60s 量级 —— 未登录/离线时的表现就是「首屏巨长」。 +-- +-- 三道语义(照参考): +-- ① 命中缓存:有远端目录就直接用(不在本闸门内) +-- ② 并发去重:同一次加载的多个调用共享同一次 +-- ③ 失败/空结果**冷却**:避免短时间内反复打同一个坏上游 +-- +-- ⚠️ 冷却只影响**拉取时机**,不影响兜底目录:冷却期内照旧回落到静态表, +-- 界面不会因此少模型。 +-- +-- ⚠️ 本 Lua 无协程(单 VM 单锁),做不了"共享同一个 Promise"。用**时间戳 +-- 闸门**达到等价效果:记录 in_flight_at(防同轮重复)+ retry_at(失败冷却)。 +local REMOTE_CATALOG_COOLDOWN = 30 -- 秒,照参考 REMOTE_CATALOG_COOLDOWN_MS + +local function catalogGateOf(provider) + plugin.state.catalogGate = plugin.state.catalogGate or {} + local g = plugin.state.catalogGate[provider] + if not g then g = {} plugin.state.catalogGate[provider] = g end + return g +end + +-- catalogGateCooling(provider):是否处于"上次没拿到目录"的冷却窗口内。 +-- +-- ⚠️ 照参考的墙钟回拨防护:`retry_at` 用墙钟算,系统时间被回拨时 +-- `now < retry_at` 会把冷却**拉长**(回拨一天就是事实上的永久卡死)。 +-- 故只认"剩余量不超过一个冷却周期"的窗口,超长的按已到期处理。 +local function catalogGateCooling(provider, now) + local g = catalogGateOf(provider) + if not g.retry_at then return false end + if now >= g.retry_at then return false end + if g.retry_at - now > REMOTE_CATALOG_COOLDOWN then return false end + return true +end + +-- catalogGateBegin(provider):尝试进入一次加载。返回 true 表示"本次由你拉"。 +local function catalogGateBegin(provider, now) + local g = catalogGateOf(provider) + if catalogGateCooling(provider, now) then return false end + -- 同一轮内的并发去重:正在飞就不再发起(参考的 inFlight) + if g.in_flight_at and (now - g.in_flight_at) < 10 then return false end + g.in_flight_at = now + return true +end + +-- catalogGateEnd(provider, got, now):收尾。拿到目录清冷却,没拿到进冷却。 +local function catalogGateEnd(provider, got, now) + local g = catalogGateOf(provider) + g.in_flight_at = nil + g.retry_at = got and nil or (now + REMOTE_CATALOG_COOLDOWN) +end + -- refresh_models():定时把远端模型目录拉回来并重同步所有源。 -- -- 为什么需要它:模型目录写进了**上游源**(源的 models 决定网关能路由哪些 -- 模型)。不刷新就只能用兜底表 —— 平台上新模型后网关永远看不到, -- 表现为"某个新模型 404 / 不可选"。 function plugin.refresh_models() - local updated, failed = 0, {} + local updated, failed, cooled = 0, {}, 0 + local now = os.time and os.time() or 0 for _, plat in ipairs(allPlatforms()) do if plat.models_remote then + -- 目录闸门:冷却期内不拉(避免对坏上游反复打;照参考 remote-catalog-gate) + if not catalogGateBegin(plat.id, now) then + cooled = cooled + 1 + goto continue + end local ids, err = refreshRemoteModels(plat.id) + catalogGateEnd(plat.id, ids ~= nil, now) if ids then updated = updated + 1 -- 目录变了就重同步该平台所有源(模型清单在源里) @@ -4980,6 +5200,11 @@ function plugin.refresh_models() failed[#failed + 1] = plat.id .. ": " .. tostring(err) end end + ::continue:: + end + -- 冷却期内的平台不算失败(它们上次已经记过原因,重复记会淹没真正的错误) + if cooled > 0 then + logEvent("模型刷新:跳过 " .. cooled .. " 个冷却中的平台") end if updated > 0 then logEvent("模型目录已刷新(" .. updated .. " 个平台)") @@ -5733,21 +5958,22 @@ function plugin.model_set_disabled_many(req) end -- model.clearDead:清死模型(对齐参考 model.clearDead:从黑名单移除已下线的 id) +-- model_clear_dead:清掉"已失效模型"的记录(照参考 clearDeadModels 语义)。 +-- +-- ⚠️ 语义修正:此前这里删的是"被禁用且不在目录里"的条目 —— 那既不是失效记录, +-- 判据也用错了(拿目录反推)。参考的 clear 是:把**有阳性 404 证据**的记录清掉, +-- 让模型重新出现在列表里(用户确认上游又上了/或误判时用)。 function plugin.model_clear_dead(req) local args = bodyOf(req) if args.platform == nil then args = req end local plat = platformById(args and args.platform) if not plat then return { status = 400, json = { error = "未知平台" } } end - local disabled = disabledModelsOf(plat.id) - local removed = {} - for mid in pairs(disabled) do - local alive = false - for _, m in ipairs(effectiveModels(plat.id)) do if m == mid then alive = true break end end - if not alive then disabled[mid] = nil removed[#removed+1] = mid end - end + local mid = args and args.model_id + local n = clearDeadModels(plat.id, mid) markDirty() for _, a in ipairs(poolList(plat.id)) do syncAccountSource(plat.id, a.id) end - return { status = 200, json = { ok = true, removed = removed } } + return { status = 200, json = { ok = true, removed = n, + message = n > 0 and ("已恢复 " .. n .. " 个模型") or "无失效模型记录" } } end -- 供应商排序(对齐参考 provider.getOrder/setOrder) @@ -7990,6 +8216,40 @@ function plugin.__store_dir_probe() return { json = { store_dir = storeDir() } } end +-- plugin.__is_model_gone_probe:只给测试用 —— 暴露失效判据本体。 +-- 判据必须能被逐条钉住:这些模式的误判代价是把**可用模型藏 30 天**。 +function plugin.__is_model_gone_probe(args) + return { json = { gone = isModelGone(tostring((args or {}).message or "")) } } +end + +-- plugin.__record_dead_probe:只给测试用 —— 记录一条失效证据。 +function plugin.__record_dead_probe(args) + args = args or {} + local isnew = recordDeadModel(tostring(args.platform or ""), tostring(args.model_id or ""), + tostring(args.reason or "")) + return { json = { recorded = isnew } } +end + +-- plugin.__catalog_gate_probe:只给测试用 —— 读闸门状态并尝试进入一次。 +function plugin.__catalog_gate_probe(args) + args = args or {} + local now = os.time and os.time() or 0 + local cooling = catalogGateCooling(tostring(args.platform or ""), now) + local began = false + if not cooling then began = catalogGateBegin(tostring(args.platform or ""), now) end + return { json = { began = began, cooling = cooling } } +end + +-- plugin.__catalog_gate_fail_probe:只给测试用 —— 模拟一次拉取失败(进冷却)。 +function plugin.__catalog_gate_fail_probe(args) + args = args or {} + local now = os.time and os.time() or 0 + local p = tostring(args.platform or "") + catalogGateBegin(p, now) + catalogGateEnd(p, false, now) + return { json = { ok = true } } +end + -- plugin.credits_capability:只给测试用 —— 暴露能力表条目,让判据能断言 -- "表与参考真值一致"(表错了只会静默少领,没有任何报错)。 function plugin.credits_capability(args)