feat(channels): 补两个参考明确"不能省"的基础设施(已失效模型 + 目录闸门)

穷尽对照 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 个包全绿。
This commit is contained in:
JianFeeeee
2026-10-14 15:11:05 +08:00
parent a020247627
commit 583976f7da
3 changed files with 500 additions and 20 deletions

View File

@ -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)