mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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 表达。
This commit is contained in:
@ -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)
|
||||
@ -7028,7 +7174,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
|
||||
@ -7063,6 +7214,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 口径,与活动结算口径一致)。
|
||||
@ -7503,7 +7662,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 "源未同步",
|
||||
@ -8803,17 +8962,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 })
|
||||
@ -8823,7 +9002,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
|
||||
@ -11912,6 +12091,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 "")
|
||||
@ -12499,7 +12772,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
|
||||
|
||||
Reference in New Issue
Block a user