refactor(scheduler)!: 移除 priority 废弃字段 + 一通道一源 + discover 复用凭据

用户判据:「priority 直接移除」「即使带 key 也没有 /v1/responses」
「channel 插件的问题还没完全解决」「生图和聊天合并到一个导航项」

## 一、删除 priority(它从未参与跨源调度)

`config.Model.Priority` 号称"高者优先参与 AUTO 选源",**那是错的**:
AUTO 链由 `config.ModelScope.Tier` 驱动(scheduler.Chain),priority 只被
`bestChatModel/bestImageModel` 用于"本源内部该发哪个模型",与跨源调度无关。
把它当调度依据会让人以为拖个数字就能改顺序,改完毫无效果。

- 删 `config.Model.Priority` 字段与 `bestChatModel/bestImageModel` 的优先级比较
- 删 `seedAuto()`:**AUTO 链为空时网关应直接 503**,不能凭空播种用户没配过的档位
- 删 UI `buildLanes` 的 discovery 回退(按源 priority 自动铺开)与 sourceRows
- 清掉 config.yaml 里 140 行 priority

## 二、一通道 = 一个适配器 = 一个源(channels 插件)

旧设计「一账号一源」(`channels-<平台>-<12位hex>`) 有三个结构性后果:
1. 三个账号 ⇒ 三个源、三份同名模型,用户看到三行无法区分的东西
2. 谁先谁后**取决于注册顺序,不是余额**(rankAccounts 只用于展示,从未接进同步路径)
3. 换号要改源名 ⇒ 用户书签里的 `源:模型` 全部失效

新设计:
- 源名只跟通道绑定:`channels-<平台>`,换号 = 同名 add_source 覆盖 api_key
  (内核 AddSource 是 UpsertSourceInYAML + 内存替换),对客户端完全透明
- `pickAccountForSource()`:凭据可用 → 余额分档(先快作废后永久)→
  跳过已标记失效的号。没有 balance 声明的平台不据此排除(不因"不会查"判死)
- `request_end` 钩子:真实请求失败且是**终态**错误(需充值/封禁/凭据永久失效)
  时给当前账号打 termErr,下次选号跳过;成功则清标记。
  ⚠️ 只认终态 —— 429/超时/抖动都**不**标记(那会把好号一个个废掉)
- `refresh_creds` 每 30 分钟巡检后重新选号(兜底心跳,纯本地判定)
- 迁移:同步时自动清掉旧版按账号拆的源(按已知平台前缀匹配,不用贪婪 match)
- 顺带修:`refresh_creds` 漏认 `refresh_fn` ⇒ codearts 的 DPoP 续期永不触发

## 三、discover 支持用已存凭据(真实缺口)

刷新已有源的模型目录,此前必须让运维**再粘一次明文 key** —— 而库里是
`enc:v1:` 密文,WebUI 也不回显明文。结果就是"想刷新 → 得重新申请 key",
很多人干脆不刷,源的目录越放越旧(实例:某源 3 个生图模型从未登记)。

- `{"name":"oin"}` ⇒ 从已存源取 base_url + 解密凭据 + adapter
- 安全边界:已要求 admin;只能探测**本网关已配置的**源(不能拿任意 URL+key
  做探测,那就成了 SSRF/密钥外带);响应不回显 key

## 四、image_models 显式声明(补元数据缺失)

`discoveredModelKind` 只看上游 modality 字段,并刻意**不从模型名推断**
(错误的 kind 会毒死 AUTO 链)。但有些网关的 /v1/models 只返回
`{id,object,created,owned_by}` —— 零 modality,于是生图模型全落成 chat,
生图端点永远 `no image source configured`。

不解这个两难:默认仍完全按元数据(不猜),但允许 `image_models` **显式声明**。
写错的 id 明确报 400,不静默造一个不存在的模型。

## 五、UI:对话与生图合并为一个导航项

两者本是同一件事的两种输入(都选模型、都发请求、都读结果),只是 kind 与端点
不同。合并只发生在**导航层**:模型下拉仍按 kind 分组/禁选(把图片模型选进 chat
会请求到错误端点)。两个面板同时存在于 DOM,切换只改 display —— 来回切不会丢
已输入的文字、对话历史或已生成的图。

## 测试

- `TestUI_ImageModeIsReachableFromChatTab`:改为钉"合并后仍可达"(分段控件、
  切换函数、面板容器、端点),并断言**旧 tab 三件套必须消失**(否则 goTab 会找
  一个不存在的面板)
- 测试夹具显式建 AUTO 链(内核不再 seedAuto):默认一源一 tier 倒序
  (TestChatAUTO 验优先级);TestChatAutoChainTierFailover 自己覆盖成同 tier
  (验 tier 内轮转)—— 两者验不同机制,不能共用一个默认
- core 的一批断言从"按账号 id 找源"改为"按平台名找源"

gateway/config/provider 全绿;core 的 channels 用例全绿。
TestRealMutants 需工作树干净后才跑(其自带的变异残留防护)。

BREAKING CHANGE: config.Model.Priority 已删除。旧的 priority 字段会被 YAML
解码静默忽略(内核不校验未知字段),因此既有 config.yaml 仍可加载,但该字段
不再有任何作用 —— 调度顺序请通过 AUTO 链的 tier 表达。

(cherry picked from commit 25970a2135)
This commit is contained in:
JianFeeeee
2026-10-19 16:26:59 +08:00
committed by root
parent efc9b37965
commit ff7051d608
30 changed files with 830 additions and 346 deletions

View File

@ -153,6 +153,14 @@ local lobsteraiClientVersion
local expiringWindowDays
local fetchAccountJSON
-- ★ 选号链路的前向声明(第 11 次踩同一个坑)
-- pickAccountForSource 定义在 syncProviderSource 旁边(~4160),但它依赖
-- accountBalanceRaw / balanceTier(~6690/6716)。不声明的话那两个名字在
-- 定义处会被解析成**全局**(nil),选号时抛
-- `attempt to call global 'accountBalanceRaw' (a nil value)` —— account_add
-- 直接 500,源根本注册不出来(已实测踩过)。
local pickAccountForSource, accountBalanceRaw, balanceTier
-- ★★ 这批前向声明必须在 PLATFORMS **之前**:平台表里的 headers_fn / claim_fn
-- 闭包会在**运行时**调用这些函数,而闭包捕获的是**词法作用域里的那个变量**。
-- 若声明写在后面(例如 line ~5479),PLATFORMS 段里的引用会被解析成**全局**
@ -4156,16 +4164,108 @@ local CHANNEL_CREDITS = {
-- 每个账号一条上游源:channels-<provider>-<accountId>。模型开关即改写
-- 这条源的 models 列表(remove+add 重建;内核 add_source 同名覆盖,但显式
-- 先 remove 更诚实,避免旧模型残留)。
--
-- ★ 现在**一个通道 = 一个适配器 = 一个源**,源名只跟通道绑定。
-- 旧设计是「一账号一源」(channels-<provider>-<accountId>),它有三个
-- 结构性后果,全都在生产里出现过:
-- 1. 三个 trae 账号 ⇒ 三个源、三份同名模型。用户在模型下拉框里看到三行
-- 无法区分的东西(channels-trae-aaa · glm-5.2 / ...bbb · glm-5.2)。
-- 2. 谁先谁后**取决于注册顺序,不是余额**。余额用完的号可能排在前面,
-- 每次都先撞它再回退。
-- 3. 换号要改源名 ⇒ 用户书签/脚本里的 `源:模型` 全部失效。
-- 新设计下换号 = 用同名 add_source 覆盖 api_key(内核 AddSource 是
-- UpsertSourceInYAML + 内存替换),对客户端完全透明。
local function providerSourceName(provider)
return "channels-" .. provider
end
-- legacy 源名(旧版按账号拆源的产物)。保留只为了迁移时能删掉旧源。
local function accountSourceName(provider, accountId)
return "channels-" .. provider .. "-" .. accountId
end
-- syncAccountSource 把某账号的上游源与(平台模型目录 - 黑名单)对齐。
-- 无凭据(占位账号)不动源。
local function syncAccountSource(provider, accountId)
-- syncProviderSource(provider):把该通道的**唯一**上游源与模型目录对齐。
--
-- ★ 这是「一个通道 = 一个适配器 = 一个源」的落点。换号完全在插件内部完成,
-- 内核只看到一个源(同名 upsert),客户端完全无感。
--
-- 幂等:可在任何“该用哪个号可能变了”的时刻重复调(账号增删改 / 余额刷新 /
-- 领取完成 / 续期成功 / 30 分钟心跳巡检)。
-- pickAccountForSource(plat):从该平台的账号池里挑一个**现在能用**的号。
--
-- 判据顺序(每一级都是真实故障的产物):
-- 1. 凭据读得出来(占位号 / 密文损坏的号直接跳过)
-- 2. 余额可查且有余量(与 rankAccounts 同口径:先用到快作废的,再用永久的)
-- • 没有 balance 声明的平台不据此排除 —— 不能因为"我们不会查"就把可用号判死
-- • 查询失败同理归入"未知",保留可用性
-- 3. 排除已打失效标记的号(request_end 钩子根据**真实请求失败**打的)
--
-- 返回 (acct, reason)。reason 用于日志,让"为什么选它"可复查。
pickAccountForSource = function(plat)
local accounts = poolList(plat.id)
if #accounts == 0 then return nil, "无账号" end
local withCred = {}
for _, a in ipairs(accounts) do
if a.enabled ~= false then
local tok
if plat.credential_shape == "full" then
tok = __llmsproxy_secrets.get(a.credentialRef)
else
tok = credentialOf(a.credentialRef)
end
if tok ~= nil and tok ~= "" then withCred[#withCred + 1] = a end
end
end
if #withCred == 0 then return nil, "无可用凭据(占位账号或密文不可读)" end
local ranked = withCred
if plat.balance then
local allowPermanent = not (plugin.state.settings
and plugin.state.settings.lock_permanent_credits)
local expiring, permanent, unknown = {}, {}, {}
for _, a in ipairs(withCred) do
local raw = accountBalanceRaw(plat.id, a.id)
if raw == nil then
unknown[#unknown + 1] = a -- 查不到 ⇒ 不据此排除
else
local tier = balanceTier(raw.expiring_balance or raw.expiringBalance,
raw.permanent_balance or raw.permanentBalance, allowPermanent)
if tier == "expiring" then expiring[#expiring + 1] = a
elseif tier == "permanent" then permanent[#permanent + 1] = a
else unknown[#unknown + 1] = a end
end
end
ranked = {}
for _, a in ipairs(expiring) do ranked[#ranked + 1] = a end
for _, a in ipairs(permanent) do ranked[#ranked + 1] = a end
for _, a in ipairs(unknown) do ranked[#ranked + 1] = a end
if #ranked == 0 then return nil, "所有账号余额均不可用" end
end
for _, a in ipairs(ranked) do
if not a.termErr then
return a, "选中账号 " .. tostring(a.id)
.. (a.nickname and a.nickname ~= "" and ("(" .. a.nickname .. ")") or "")
end
end
return ranked[1], "所有账号均标记失效,回退到 " .. tostring(ranked[1].id)
end
local function syncProviderSource(provider)
local plat = platformById(provider)
local acct = poolGet(provider, accountId)
if not plat or not acct then return end
if not plat then return end
-- ★ 自动选号:凭据可用 + 余额分档 + 跳过已标失效的号。
-- 判据全部在插件内(内核不知道账号概念)。
local acct, why = pickAccountForSource(plat)
if not acct then
-- 没有可用账号 ⇒ **撤掉源**。留着旧源会让模型继续出现在列表里却每次都
-- 失败(用户无法区分"我没配"和"配了但坏了")。
local gone = providerSourceName(provider)
__llmsproxy_kernel.call("remove_source", { name = gone })
if plugin.state.chosen then plugin.state.chosen[provider] = nil end
logEvent("通道 " .. provider .. " 无可用账号,已撤源:" .. tostring(why), "warn", provider)
return
end
-- 凭据有两种消费方式,取决于平台的适配器:
-- * 多数平台:适配器发 `Authorization: Bearer <access_token>`
-- → 这里必须交出**裸 access token**(不能把整个 JSON 当 key 发上去)
@ -4182,8 +4282,15 @@ local function syncAccountSource(provider, accountId)
tok = credentialOf(acct.credentialRef)
err = (tok == nil or tok == "") and "无凭据" or nil
end
if err then return end
local name = accountSourceName(provider, accountId)
if err then
logEvent("通道 " .. provider .. " 凭据读取失败,跳过同步", "warn", provider)
return
end
-- ★ 记录"当前为该通道服务的账号"。request_end 钩子靠它把真实请求的
-- 失败原因归因到具体账号(否则只能知道“这个通道坏了”,不知道是哪个号)。
plugin.state.chosen = plugin.state.chosen or {}
plugin.state.chosen[provider] = acct.id
local name = providerSourceName(provider)
local models = {}
local disabled = disabledModelsOf(provider)
-- 用**有效目录**(远端优先、兜底补位),不是硬编码表
@ -4248,20 +4355,46 @@ local function syncAccountSource(provider, accountId)
for _, mk in ipairs({ "uid", "device_id", "machine_id", "enterprise_id" }) do
if cmeta[mk] ~= nil and cmeta[mk] ~= "" then src.meta[mk] = cmeta[mk] end
end
end
end
end
local r, aerr = __llmsproxy_kernel.call("add_source", { source = src })
-- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会
-- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。
-- ★ 先清掉旧版的**按账号拆的源**(迁移)。不清的话升级后旧源还在,
-- 模型列表里会有重复项,且旧源指向已经不该用的账号。
-- 按已知平台 id 前缀匹配(不用贪婪模式:账号 id 可能含 `-`)。
-- 只在真正要写源时才扫一遍(低频)。
do
local ls = __llmsproxy_kernel.call("list_sources", {})
local arr = (ls and ls.json and ls.json.sources) or (ls and ls.sources) or {}
if type(arr) ~= "table" then arr = {} end
local legacyPrefix = "channels-" .. provider .. "-"
for _, exist in ipairs(arr) do
local sn = type(exist) == "table" and exist.name or nil
if type(sn) == "string" and sn ~= name
and sn:sub(1, #legacyPrefix) == legacyPrefix then
__llmsproxy_kernel.call("remove_source", { name = sn })
logEvent("已清理旧版按账号拆源 " .. sn, "task", provider)
end
end
end
local r, aerr = __llmsproxy_kernel.call("add_source", { source = src })
-- 失败要**记日志**:源注册失败此前是静默的(插件没权限、凭据形状不符都会
-- 走到这里),表现为"账号加了但不出源",排查时没有任何线索。
if aerr then
logEvent("接入源失败 " .. name .. ": " .. tostring(aerr), "error", provider)
elseif r and r.json and r.json.ok == false then
logEvent("接入源失败 " .. name .. ": " .. tostring(r.json.error), "error", provider)
else
logEvent("已同步源 " .. name .. "(" .. #models .. " 模型)", "task", provider)
logEvent("已同步源 " .. name .. "(" .. #models .. " 模型,账号 "
.. tostring(acct.id) .. ")", "task", provider)
end
end
-- syncAccountSource 保留为**兼容壳**:旧调用点很多(13 处),逐个改风险大。
-- 语义变为"这个平台的源该重算了"(**忽略** accountId)—— 因为现在一通道只有
-- 一个源,账号维度的差异完全由 syncProviderSource 内部消化。
local function syncAccountSource(provider, _accountId)
syncProviderSource(provider)
end
-- dropAccountSource:把某账号的上游源撤掉(账号停用/删除时调用)。
--
-- ★ 必须**检查返回值**:内核的 remove_source 需要**单独授予**(它是 Destructive 级,
@ -4272,19 +4405,32 @@ end
-- 的 remove 失败**看不出来**(add 会把同名覆盖掉)—— 只有纯删除路径才暴露。
-- 故这里把失败**记进事件日志**,让它在面板上可见,而不是只存在于一个被丢弃的返回值里。
local function dropAccountSource(provider, accountId)
local name = accountSourceName(provider, accountId)
-- ★ 语义变了(一通道一源之后):账号被删/停用**不能**直接把通道源撤掉 ——
-- 那个源现在代表**整个通道**,池里可能还有别的号能用。正确做法是重新
-- 选号:syncProviderSource 会挑下一个可用账号并用同名覆盖,没有可用
-- 账号时它自己会撤源。
--
-- 迁移期要兼顾旧状态:如果库里还有旧版按账号拆的源(channels-p-<id>),
-- 那个才是该删的。两步都做。
local legacy = accountSourceName(provider, accountId)
-- ★ 内核桥是 `call(name, args) -> (result, nil) | (nil, err)`:
-- 失败时**第一个返回值是 nil**,原因在**第二个返回值**里。
-- 我第一版写成 `local r = call(...)` 再读 `r.ok` ⇒ 恒 nil ⇒ 日志只说"无返回",
-- 把真正原因(not granted / 超时 / 拒删最后一个源)丢掉 —— 一句不可操作的
-- 诊断比没有更糟。两个返回值都要接。
local r, err = __llmsproxy_kernel.call("remove_source", { name = name })
local r, err = __llmsproxy_kernel.call("remove_source", { name = legacy })
if r == nil or r.ok ~= true then
logEvent("撤源失败 " .. tostring(name) .. ": " .. tostring(err or "(无错误信息)")
.. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)",
"error", provider)
return false, err
-- 旧源不存在是**正常**的(新部署根本没启过它),不当作失败。
local msg = tostring(err or "")
if not msg:find("not found", 1, true) and not msg:find("不存在", 1, true) then
logEvent("撤旧源失败 " .. tostring(legacy) .. ": " .. tostring(err or "(无错误信息)")
.. " | 若为 not granted,需给 channels 插件授予 remove_source(声明在 plugin.kernel)",
"error", provider)
return false, err
end
end
-- 重算通道源:池里还有别的号就自动接上,没了就撤掉整个通道源。
syncProviderSource(provider)
return true
end
@ -6223,7 +6369,7 @@ function plugin.complete_login(req)
end
logEvent("账号凭据已入池 " .. plat.id .. "/" .. aid)
syncAccountSource(plat.id, aid)
return { status = 200, json = { ok = true, account_id = aid, source = accountSourceName(plat.id, aid) } }
return { status = 200, json = { ok = true, account_id = aid, source = providerSourceName(plat.id) } }
end
-- 兼容旧路径:单账号模式(e2e 与既有端点仍走它)。
@ -6671,7 +6817,7 @@ end
-- "expiring" 有 15 天内到期的积分 → 最优先(不用就作废)
-- "permanent" 只剩永久积分 → 次优先;锁定永久积分时视为不可用
-- "none" 都没有 → 不可用
local function balanceTier(expiring, permanent, allow_permanent)
balanceTier = function(expiring, permanent, allow_permanent)
local function pos(v)
local n = tonumber(v)
if n and n > 0 then return n end
@ -6697,7 +6843,7 @@ end
-- accountBalanceRaw(provider, accountId):取该账号的余额原料(带 TTL 缓存)。
-- 失败**不抛错**(照参考:查询失败归入最后一档),返回 nil 让调用方降级。
local function accountBalanceRaw(provider, accountId)
accountBalanceRaw = function(provider, accountId)
-- 用通用 TTL 缓存(照参考 ttl-cache):命中即返回、在飞去重、失败不缓存。
-- ⚠️ 缓存的是**原料**(包列表明细),分桶每次都现算 —— 见本节的注释。
local plat = platformById(provider)
@ -7029,7 +7175,12 @@ function plugin.refresh_creds()
local now = os.time and os.time() or 0
local refreshed, skipped, failed = 0, 0, {}
for _, plat in ipairs(allPlatforms()) do
if plat.exchange_body or plat.refresh_url then
-- ★ 三种续期声明都要认:声明式 refresh.url、旧的 refresh_url、钩子式 refresh_fn。
-- 真实缺陷:此前只认前两种 ⇒ codearts 的 refresh_fn 挂上了也永远不会被
-- 定时任务触发,token 到期后源就静静地 400。refresh_creds 是这条链路唯一入口。
if plat.exchange_body or plat.refresh_url
or (type(plat.refresh) == "table" and plat.refresh.url)
or type(plat.refresh_fn) == "function" then
for _, a in ipairs(poolList(plat.id)) do
local cred = credentialFull(a.credentialRef)
if type(cred) == "table" then
@ -7064,6 +7215,14 @@ function plugin.refresh_creds()
if #failed > 0 then
logEvent("凭据续期失败 " .. #failed .. " 个:" .. plugin.utf8Clip(table.concat(failed, "; "), 300), "warn")
end
-- ★ 每次续期巡检后重新选号(每 30 分钟一次,是「余额/凭据变了」的自然节拍)。
-- 一通道一源:换号 = 同名 add_source 改 api_key,对客户端完全透明。
-- 这是「插件自动察觉账号余额用尽并切到有余额的账号」的**兜底心跳** ——
-- 即使没有任何事件触发(没人点刷新、没登录新号),最迟 30 分钟内也会
-- 重新挑一次号。纯本地判定(余额有 TTL 缓存),不产生上游请求。
for _, plat in ipairs(allPlatforms()) do
syncProviderSource(plat.id)
end
end
-- todayKey():当天标识(UTC+8 口径,与活动结算口径一致)。
@ -7504,7 +7663,7 @@ function plugin.account_test(req)
local srcs, _ = __llmsproxy_kernel.call("list_sources", {})
local found = false
for _, s in ipairs(srcs or {}) do
if s.name == accountSourceName(plat.id, acct.id) then found = true break end
if s.name == providerSourceName(plat.id) then found = true break end
end
return { status = 200, json = { ok = found, probed = false,
error = found and nil or "源未同步",
@ -8804,17 +8963,37 @@ function plugin.reap_orphan_sources(req)
local found, removed, failed = {}, 0, {}
for _, x in ipairs(arr) do
local nm = type(x) == "table" and x.name or nil
if type(nm) == "string" then
local plat, acct = nil, nil
for _, p in ipairs(PLATFORMS) do
local pref = "channels-" .. p.id .. "-"
if nm:sub(1, #pref) == pref then
plat, acct = p.id, nm:sub(#pref + 1)
break
local nm = type(x) == "table" and x.name or nil
if type(nm) == "string" then
local plat, acct, why = nil, nil, nil
-- 形态 1(旧版残留):channels-<平台>-<账号id>,账号已不在池里 ⇒ 孤儿。
-- 按**已知平台前缀**匹配,不能用 `channels-(.+)-(.+)$`(Lua 的 `-` 是贪婪的,
-- 账号 id 含 `-` 会被切错 —— 判据里专门放了 dead-acct-1 咬这一条)。
for _, p in ipairs(PLATFORMS) do
local pref = "channels-" .. p.id .. "-"
if nm:sub(1, #pref) == pref then
plat, acct = p.id, nm:sub(#pref + 1)
break
end
end
end
if plat and acct ~= "" and poolGet(plat, acct) == nil then
if plat and acct ~= "" and poolGet(plat, acct) == nil then
why = "账号 " .. acct .. " 已不在池中"
end
-- 形态 2(一通道一源之后的真实孤儿):channels-<平台> 但该平台
-- **没有任何可用账号**(全删了/全停用/凭据全读不出来)。
if plat == nil then
for _, p in ipairs(PLATFORMS) do
if nm == "channels-" .. p.id then
local pok, acc, areason = pcall(pickAccountForSource, p)
if not pok then acc = nil areason = "选号失败: " .. tostring(acc) end
if not acc then
plat, why = p.id, "该通道无可用账号:" .. tostring(areason)
end
break
end
end
end
if plat and why then
found[#found + 1] = nm
if not dry then
local r, err = __llmsproxy_kernel.call("remove_source", { name = nm })
@ -8824,7 +9003,7 @@ function plugin.reap_orphan_sources(req)
"error", plat)
else
removed = removed + 1
logEvent("孤儿源已清理 " .. nm .. "(账号 " .. acct .. " 已不在池中)", "warn", plat)
logEvent("孤儿源已清理 " .. nm .. "(" .. tostring(why) .. ")", "warn", plat)
end
end
end
@ -11913,6 +12092,100 @@ end
-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。
-- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则
-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。
-- ============================================================================
-- 账号有效性反馈:从真实请求结果里学习「哪个号该被跳过」
-- ============================================================================
--
-- 为什么需要这条链路
-- ------------------
-- 选号(pickAccountForSource)判的是**余额**,但余额查询有三个盲区:
-- 1. 有些平台没有余额端点 ⇒ accountBalanceRaw 恒 nil
-- 2. 余额查询有 TTL 缓存(60s)⇒ 用尽的瞬间不会立刻反映
-- 3. 余额 > 0 但账号被风控/封禁 ⇒ 余额看起来完好,实际全失败
--
-- 唯一能覆盖这三点的证据是**真实请求的失败原因**。所以挂 request_end:
-- 当这个通道的请求失败、且错误文本是「终态」的(需充值 / 封禁 / 凭据失效)时,
-- 给当前选中的账号打 termErr 标记,下一次选号跳开它。
--
-- ⚠️ 只认**终态**错误。普通 429 限流、超时、网络抖动都**不**标记 ——
-- 那些会自动恢复,标记它们等于把好号一个个废掉(把一次请求层面的拒绝
-- 当终态,代价是把材料完好的账号一步标死)。
--
-- 解除标记的路径:请求成功 / 账号重新登录 / 手动 account_reset。
-- plugin.term_error_kind(msg):把错误文本分类为终态错误,返回原因或 nil。
function plugin.term_error_kind(msg)
if type(msg) ~= "string" or msg == "" then return nil end
local low = msg:lower()
-- 额度类:明确说用尽且需要充值(不是"当日免费额度,明天恢复"那种)
if low:find("insufficient", 1, true) or low:find("余额不足", 1, true)
or low:find("请充值", 1, true) or low:find("insufficient_balance", 1, true)
or low:find("额度已用完", 1, true) then
return "余额不足(需充值)"
end
-- 封禁 / 风控 / 账号停用
if low:find("banned", 1, true) or low:find("封禁", 1, true)
or low:find("账号已停用", 1, true) then
return "账号被封禁/停用"
end
-- 凭据失效(refresh_token 已作废这类不可自愈的)
if low:find("refresh token has been used", 1, true)
or low:find("invalid_grant", 1, true) then
return "凭据已永久失效(需重新登录)"
end
return nil
end
-- plugin.mark_account_term_error(provider, kind):给**当前为该通道服务**的号打标记。
function plugin.mark_account_term_error(provider, kind)
local plat = platformById(provider)
if not plat then return end
local cur = plugin.state.chosen and plugin.state.chosen[provider]
if not cur then return end
local acct = poolGet(provider, cur)
if not acct then return end
if acct.termErr == kind then return end -- 已经标过,别刷日志
acct.termErr = kind
acct.termErrAt = os.time and os.time() or 0
poolAdd(provider, acct)
markDirty()
logEvent("账号 " .. tostring(cur) .. " 标记失效:" .. tostring(kind)
.. " —— 下次选号将跳过它", "warn", provider)
end
-- 插件钩子:request_end 在每次请求结束时触发。
-- ⚠️ 声明 hooks 不妨碍自有 HTTP 路由 —— 本插件的状态变更仍走自己的路由。
plugin.hooks = { request_end = "on_request_end" }
function plugin.on_request_end(payload)
if type(payload) ~= "table" then return nil end
local src = tostring(payload.source or "")
-- 只处理本插件管辖的通道(`channels-<provider>`),其他源不碰。
if src:sub(1, 9) ~= "channels-" then return nil end
local provider = src:sub(10)
local cur = plugin.state.chosen and plugin.state.chosen[provider]
if not cur then return nil end
local acct = poolGet(provider, cur)
if not acct then return nil end
if payload.ok then
-- 成功 ⇒ 清掉失效标记(它显然能用)。
if acct.termErr then
acct.termErr = nil
acct.termErrAt = nil
poolAdd(provider, acct)
markDirty()
logEvent("账号 " .. tostring(cur) .. " 恢复正常(请求成功)", "info", provider)
end
return nil
end
-- 失败 ⇒ 只有"终态"错误才标记。payload 里的错误文本字段名不定,
-- 三个都看(内核不同路径填的字段不同)。
local msg = tostring(payload.error or payload.message or payload.error_message or "")
local kind = plugin.term_error_kind(msg)
if kind then plugin.mark_account_term_error(provider, kind) end
return nil
end
function plugin.credits_tier_probe(args)
args = args or {}
local provider = tostring(args.platform or "")
@ -12500,7 +12773,8 @@ function plugin.__sync_source_probe(args)
-- 回报同步后内核里该源是否存在
local ls = __llmsproxy_kernel.call("list_sources", {})
local found, ad = false, nil
local want = accountSourceName(args.platform, args.account_id)
-- 一通道一源之后,同步的目标是**通道级**源名;account_id 只用于选号。
local want = providerSourceName(args.platform)
local arr = (ls and ls.json and ls.json.sources) or (ls and ls.sources) or {}
for _, x in ipairs(arr) do
if x.name == want then found = true ad = x.adapter end