mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): 余额分档选号(照参考 balance-rank/selector)
解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」), 而**服务端扣哪个包不由我们决定** —— 插件能做的是决定**用哪个号**: - 优先用「还有 15 天内到期积分」的号(那部分不用就作废) - 只剩永久积分的号排最后 - **锁定永久积分**时,只剩永久积分的号**等同于不可用** 为什么是 15 天(照参考的实测对照表,两站差别极大): - WorkBuddy 5 个号:Bonus Pack 250(8~9 天)+ Free Plan 100(3008 天) → 锁定后 4 个号仍可用、1 个不可用,正是期望效果 - CodeBuddy 中国版:裂变包距到期**最少 17 天** → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」—— 判据没错,这类池想用锁定要把窗口放宽 实现要点(三条都是参考点名的坑): 1. **只缓存原料,绝不冻结分类**。「临时/永久」是**当前时刻的函数**,而宿主长期 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就 越过了线。若把分桶结果也缓存,选号会继续按「永久」处理一笔马上要作废的积分 (锁定时更糟:本该可用的号被判成不可用)。故缓存 60s 的**包列表明细**, 每次都重新分桶。 2. **失效包必须跳过**(`active === false`):服务端仍返回它们的余额,并进任何 一桶都会虚增可用额度。 3. **到期时间未知归入永久桶**:保守方向 —— 最坏是锁定时少用一个号, 而不是误把长期积分当快到期烧掉。 口径与既有 `total_fn` 一致:只算 **remaining**(CycleCapacityRemain,本周期剩余), 与 IDE 顶部的 Credits Balance 一致。刻意不用 TotalDosage —— 那是**终身口径** 且取整(实测同一响应 TotalDosage=655 而 IDE 显示 155.67)。 ★ 两个 Lua 环境的实测发现(写在这里免得下次再踩): - 本 VM 的 **os 表没有 setenv**(实测键:clock/date/difftime/execute/exit/ getenv/remove/rename/setlocale/time/tmpname),所以"面板改窗口"必须走 settings 而不是设环境变量;getenv 只用于读部署期注入值。 - `os.setenv and os.setenv(...)` 是**语法错误** —— `and` 是表达式,不能作语句。 判据(channels_balance_tier_test.go,假余额上游): 临时桶=100(只有 10 天内那个)、永久桶=50(远期+未知)、失效包的 9999 被跳过、 档位判定、锁定后仍可用(有临时积分)、窗口设 0 后临时桶归零且锁定后不可用。 回归:9 个包全绿。
This commit is contained in:
@ -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", "__is_model_gone_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", "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", "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" }
|
||||
|
||||
|
||||
|
||||
@ -84,6 +84,11 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda
|
||||
local qoderMachineIdentity, rfc3986Escape, newToken
|
||||
local lobsteraiClientVersion
|
||||
|
||||
-- 前向声明(本文件第 10 次踩 local 前向引用):余额分档选号在前、具体抓取在后。
|
||||
-- expiringWindowDays / fetchAccountJSON / fetchBalanceOf 都定义在下方。
|
||||
local expiringWindowDays
|
||||
local fetchAccountJSON
|
||||
|
||||
local PLATFORMS = {
|
||||
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
|
||||
-- -- 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的
|
||||
@ -400,6 +405,52 @@ local PLATFORMS = {
|
||||
balance = {
|
||||
url = "https://copilot.tencent.com/v2/billing/meter/get-user-resource",
|
||||
method = "POST", body = "{}",
|
||||
-- tier_fn:把余额拆成「快到期 / 永久」两桶(照参考
|
||||
-- buddy-balance-rank.ts:splitBuddyCreditsByExpiry)。口径必须与 total_fn
|
||||
-- 一致 —— 两条路各算一遍必然会漂移,故共用同一套包遍历规则:
|
||||
-- * **只算 remaining**(CycleCapacityRemain,本周期剩余),与 IDE 顶部
|
||||
-- 的 Credits Balance 一致。刻意不用 TotalDosage:那是**终身口径**
|
||||
-- 且取整(实测同一响应 TotalDosage=655 而 IDE 显示 155.67)。
|
||||
-- * `active === false` 的包一律**跳过**:失效包服务端仍会返回余额,
|
||||
-- 并进任何一桶都会虚增可用额度。
|
||||
-- * 到期时间**未知**归入**永久**桶 —— 保守方向:最坏是锁定时少用一个号,
|
||||
-- 而不是误把长期积分当快到期烧掉。
|
||||
tier_fn = function(rec)
|
||||
local data = rec.data
|
||||
if type(data) ~= "table" then return nil end
|
||||
local resp = data.Response
|
||||
if type(resp) ~= "table" then return nil end
|
||||
local inner = resp.Data
|
||||
if type(inner) ~= "table" then return nil end
|
||||
local accounts = inner.Accounts
|
||||
if type(accounts) ~= "table" then return nil end
|
||||
local now = os.time and os.time() or 0
|
||||
local window = expiringWindowDays() * 86400
|
||||
local expiring, permanent = 0, 0
|
||||
for _, pkg in ipairs(accounts) do
|
||||
if type(pkg) == "table" then
|
||||
local active = true
|
||||
local st = pkg.Status
|
||||
if st == 2 or st == "2" or st == "EXPIRED" or st == "expired" then active = false end
|
||||
local ded = tonumber(pkg.DeductionEndTime)
|
||||
local endSec = nil
|
||||
if ded and ded > 0 then
|
||||
endSec = math.floor(ded / 1000)
|
||||
if now >= endSec then active = false end
|
||||
end
|
||||
local remain = tonumber(pkg.CycleCapacityRemain) or 0
|
||||
if active and remain > 0 then
|
||||
-- 到期时间已知且落在窗口内 → 临时桶;未知 → 永久桶(保守)
|
||||
if endSec and (endSec - now) < window then
|
||||
expiring = expiring + remain
|
||||
else
|
||||
permanent = permanent + remain
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return { expiring_balance = expiring, permanent_balance = permanent }
|
||||
end,
|
||||
total_fn = function(rec)
|
||||
local data = rec.data
|
||||
if type(data) ~= "table" then return nil end
|
||||
@ -4985,6 +5036,148 @@ plugin.schedule = {
|
||||
|
||||
-- ============================================================================
|
||||
-- ============================================================================
|
||||
-- ============================================================================
|
||||
-- 余额分档选号(照参考 buddy-balance-rank.ts / loomy-balance-rank.ts)
|
||||
-- ============================================================================
|
||||
-- 解决什么:一个号常同时持有多**种**资源包(如「14 天到期」+「8 年后扣费截止」),
|
||||
-- 而**服务端扣哪个包不由我们决定**。插件能做的是决定**用哪个号**:
|
||||
-- * 优先用「还有 15 天内到期积分」的号(那部分再不用就作废)
|
||||
-- * 只剩永久积分的号排最后
|
||||
-- * **锁定永久积分**时,只剩永久积分的号**等同于不可用**
|
||||
--
|
||||
-- 为什么是 15 天(实测两站差别极大,参考有对照表):
|
||||
-- WorkBuddy 5 个号:Bonus Pack 250(8~9 天)+ Free Plan 100(3008 天)
|
||||
-- → 锁定后有 4 个号仍可用,1 个(Bonus Pack 已耗尽)不可用,正是期望效果
|
||||
-- CodeBuddy 中国版 1 个号:裂变包共 10064.6,**距到期最少 17 天**
|
||||
-- → 17 > 15 ⇒ 全算永久 ⇒ 锁定后立刻「无可用账号」
|
||||
-- 中国版按 30 天发包,故"距到期"天然落在 17~30 天 —— 判据本身没错,这类池
|
||||
-- 想用锁定就得把窗口放宽(如设 31)。窗口**只能由用户显式改**,默认 15。
|
||||
local EXPIRING_WINDOW_DAYS_DEFAULT = 15
|
||||
|
||||
expiringWindowDays = function()
|
||||
-- 优先级:settings(面板可改)→ env(部署可改)→ 默认 15。
|
||||
--
|
||||
-- ⚠️ **不能**写成 `tonumber(raw) or 默认`:0 是合法值(表示"没有临时积分",
|
||||
-- 即全部算永久)。参考在这一点上专门写了注释。
|
||||
local cfg = plugin.state.settings and plugin.state.settings.expiring_window_days
|
||||
if cfg ~= nil then
|
||||
local n = tonumber(cfg)
|
||||
if n and n >= 0 then return n end
|
||||
end
|
||||
-- ⚠️ 本 VM 的 os 表**没有** setenv(实测:只有 clock/date/difftime/execute/
|
||||
-- exit/getenv/remove/rename/setlocale/time/tmpname),所以面板改值必须走
|
||||
-- settings 而不是"设环境变量"。getenv 只用于读取部署期注入的值。
|
||||
local raw = os.getenv and os.getenv("DSH_BUDDY_EXPIRING_WINDOW_DAYS")
|
||||
if type(raw) == "string" and raw ~= "" then
|
||||
local n = tonumber(raw)
|
||||
if n and n >= 0 then return n end
|
||||
end
|
||||
return EXPIRING_WINDOW_DAYS_DEFAULT
|
||||
end
|
||||
|
||||
-- balanceTier(expiring, permanent, allow_permanent):
|
||||
-- "expiring" 有 15 天内到期的积分 → 最优先(不用就作废)
|
||||
-- "permanent" 只剩永久积分 → 次优先;锁定永久积分时视为不可用
|
||||
-- "none" 都没有 → 不可用
|
||||
local function balanceTier(expiring, permanent, allow_permanent)
|
||||
local function pos(v)
|
||||
local n = tonumber(v)
|
||||
if n and n > 0 then return n end
|
||||
return 0
|
||||
end
|
||||
if pos(expiring) > 0 then return "expiring" end
|
||||
if allow_permanent == false then return "none" end
|
||||
if pos(permanent) > 0 then return "permanent" end
|
||||
return "none"
|
||||
end
|
||||
|
||||
local function tierUsable(tier)
|
||||
return tier ~= "none"
|
||||
end
|
||||
|
||||
-- 余额缓存(TTL 60 秒,照参考)。**只缓存原料,绝不冻结分类** ——
|
||||
-- 「临时/永久」是**当前时刻的函数**(到期时间 - now 与窗口比较),而宿主长期
|
||||
-- 开着、时间只向前流:一笔距到期 15.02 天的余额,在用户什么都不做的两分钟里就
|
||||
-- 越过了线。若把分类也缓存,选号会继续按"永久"处理一笔马上要作废的积分
|
||||
-- (锁定时更糟:本该可用的号被判成不可用)。故命中缓存也**每次重新分桶**。
|
||||
local BALANCE_CACHE_TTL = 60
|
||||
|
||||
local function balanceCacheOf(provider, accountId)
|
||||
plugin.state.balanceCache = plugin.state.balanceCache or {}
|
||||
local k = provider .. ":" .. accountId
|
||||
return plugin.state.balanceCache, k
|
||||
end
|
||||
|
||||
-- accountBalanceRaw(provider, accountId):取该账号的余额原料(带 TTL 缓存)。
|
||||
-- 失败**不抛错**(照参考:查询失败归入最后一档),返回 nil 让调用方降级。
|
||||
local function accountBalanceRaw(provider, accountId)
|
||||
local cache, k = balanceCacheOf(provider, accountId)
|
||||
local now = os.time and os.time() or 0
|
||||
local hit = cache[k]
|
||||
if hit and (now - (hit.at or 0)) < BALANCE_CACHE_TTL then
|
||||
return hit.raw
|
||||
end
|
||||
local plat = platformById(provider)
|
||||
local acct = poolGet(provider, accountId)
|
||||
if not plat or not acct or not plat.balance then return nil end
|
||||
local tok = credentialOf(acct.credentialRef)
|
||||
if not tok or tok == "" then return nil end
|
||||
-- 复用既有的余额抓取(credits_balances 用的同一个助手,避免两处口径漂移)
|
||||
local rec, err = fetchAccountJSON(plat.balance, tok, credentialFull(acct.credentialRef), plat)
|
||||
local raw = nil
|
||||
if not err and type(rec) == "table" then
|
||||
if plat.balance.tier_fn then
|
||||
-- 平台声明了分档提取器(buddy/loomy 的包列表要按到期时间现算)
|
||||
local okf, t = pcall(plat.balance.tier_fn, rec)
|
||||
if okf and type(t) == "table" then raw = t end
|
||||
elseif plat.balance.total_fn then
|
||||
-- 只有总量的平台:全部算"永久"(无到期信息 ⇒ 不会凭空造出临时档)
|
||||
local okf, total = pcall(plat.balance.total_fn, rec)
|
||||
if okf and type(total) == "number" then
|
||||
raw = { permanent_balance = total, expiring_balance = 0 }
|
||||
end
|
||||
end
|
||||
end
|
||||
cache[k] = { at = now, raw = raw }
|
||||
return raw
|
||||
end
|
||||
|
||||
-- rankAccounts(provider):按"积分何时作废"优先排序该平台的账号。
|
||||
-- 返回 {usable={...}, all={...}}。查询失败的账号归入最后一档(照参考)。
|
||||
local function rankAccounts(provider)
|
||||
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
|
||||
local allowPermanent = not lockPermanent
|
||||
local expiring, permanent, none = {}, {}, {}
|
||||
for _, a in ipairs(poolList(provider)) do
|
||||
if a.enabled ~= false then
|
||||
local raw = accountBalanceRaw(provider, a.id)
|
||||
local tier
|
||||
if raw == nil then
|
||||
tier = "none" -- 查不到 → 最后(不编造)
|
||||
else
|
||||
tier = balanceTier(raw.expiring_balance or raw.expiringBalance,
|
||||
raw.permanent_balance or raw.permanentBalance, allowPermanent)
|
||||
end
|
||||
if tier == "expiring" then expiring[#expiring+1] = a
|
||||
elseif tier == "permanent" then permanent[#permanent+1] = a
|
||||
else none[#none+1] = a end
|
||||
end
|
||||
end
|
||||
local all = {}
|
||||
for _, a in ipairs(expiring) do all[#all+1] = a end
|
||||
for _, a in ipairs(permanent) do all[#all+1] = a end
|
||||
for _, a in ipairs(none) do all[#all+1] = a end
|
||||
-- 可用 = 临时档(永远)+ 永久档(仅未锁定时)
|
||||
local usable = {}
|
||||
for _, a in ipairs(expiring) do usable[#usable+1] = a end
|
||||
if allowPermanent then
|
||||
for _, a in ipairs(permanent) do usable[#usable+1] = a end
|
||||
end
|
||||
return { usable = usable, all = all, expiring = #expiring,
|
||||
permanent = #permanent, none = #none,
|
||||
locked = lockPermanent and true or false }
|
||||
end
|
||||
|
||||
-- 已失效模型(照参考 dead-model-store.ts)
|
||||
-- ============================================================================
|
||||
-- 解决的真实报障:用户选中某平台的兜底模型,每轮都 404 ——
|
||||
@ -5797,7 +5990,7 @@ end
|
||||
-- 签到状态 + 逐账号余额(对齐参考 credits.status / credits.balances)
|
||||
-- ============================================================================
|
||||
-- fetchAccountJSON 用某账号凭据发一次请求,返回 (rec_table, nil) 或 (nil, err)。
|
||||
local function fetchAccountJSON(spec, tok, credTable, plat)
|
||||
fetchAccountJSON = function(spec, tok, credTable, plat)
|
||||
local headers
|
||||
-- 头钩子位:spec(checkin_status / balance)自己的优先,否则用平台级的。
|
||||
-- buddy 在平台层声明一次,签到与余额复用同一套身份头。
|
||||
@ -8216,6 +8409,65 @@ function plugin.__store_dir_probe()
|
||||
return { json = { store_dir = storeDir() } }
|
||||
end
|
||||
|
||||
-- plugin.credits_tier_probe:只给测试用 —— 单账号的余额分档结果。
|
||||
-- 暴露 expiring/permanent/tier/usable 四个值,让判据能逐条钉住分档规则
|
||||
-- (含"失效包必须跳过""到期未知归永久""锁定永久积分时只剩永久的号不可用")。
|
||||
function plugin.credits_tier_probe(args)
|
||||
args = args or {}
|
||||
local provider = tostring(args.platform or "")
|
||||
local accountId = tostring(args.account_id or "")
|
||||
local raw = accountBalanceRaw(provider, accountId)
|
||||
if raw == nil then return { json = { error = "余额不可用" } } end
|
||||
local lockPermanent = plugin.state.settings and plugin.state.settings.lock_permanent_credits
|
||||
local allowPermanent = not lockPermanent
|
||||
local tier = balanceTier(raw.expiring_balance, raw.permanent_balance, allowPermanent)
|
||||
return { json = {
|
||||
expiring = raw.expiring_balance or 0,
|
||||
permanent = raw.permanent_balance or 0,
|
||||
tier = tier,
|
||||
usable = tierUsable(tier),
|
||||
locked = lockPermanent and true or false,
|
||||
window_days = expiringWindowDays(),
|
||||
} }
|
||||
end
|
||||
|
||||
-- plugin.__set_lock_permanent_probe:只给测试用 —— 设置"锁定永久积分"开关。
|
||||
function plugin.__set_lock_permanent_probe(args)
|
||||
plugin.state.settings = plugin.state.settings or {}
|
||||
plugin.state.settings.lock_permanent_credits = (args or {}).lock and true or false
|
||||
return { json = { ok = true, locked = plugin.state.settings.lock_permanent_credits } }
|
||||
end
|
||||
|
||||
-- plugin.__set_expiring_window_probe:只给测试用 —— 覆盖"临时积分"窗口天数。
|
||||
-- 用环境变量实现(与生产同一路径,避免测试走旁路而与真实行为脱节)。
|
||||
function plugin.__set_expiring_window_probe(args)
|
||||
local d = tonumber((args or {}).days)
|
||||
if d == nil then return { json = { error = "缺 days" } } end
|
||||
plugin.state.settings = plugin.state.settings or {}
|
||||
plugin.state.settings.expiring_window_days = d
|
||||
-- 窗口变了必须让余额缓存失效:缓存的是**原料**(包列表),而分桶是每次现算的,
|
||||
-- 但原料里的"有效/失效"判定也依赖窗口吗?不依赖 —— 依赖窗口的只有分桶。
|
||||
-- 清缓存是为了让判据能立即看到新窗口的效果(生产里 60s 后自然过期)。
|
||||
plugin.state.balanceCache = {}
|
||||
return { json = { ok = true, days = expiringWindowDays() } }
|
||||
end
|
||||
|
||||
-- plugin.__rank_accounts_probe:只给测试用 —— 暴露余额分档选号的结果。
|
||||
function plugin.__rank_accounts_probe(args)
|
||||
args = args or {}
|
||||
local r = rankAccounts(tostring(args.platform or ""))
|
||||
local ids = {}
|
||||
for _, a in ipairs(r.all or {}) do ids[#ids+1] = a.id end
|
||||
return { json = { expiring = r.expiring, permanent = r.permanent,
|
||||
none = r.none, locked = r.locked, all = ids,
|
||||
usable_n = #(r.usable or {}) } }
|
||||
end
|
||||
|
||||
-- plugin.__expiring_window_probe:只给测试用 —— 读当前生效的窗口天数。
|
||||
function plugin.__expiring_window_probe()
|
||||
return { json = { days = expiringWindowDays() } }
|
||||
end
|
||||
|
||||
-- plugin.__is_model_gone_probe:只给测试用 —— 暴露失效判据本体。
|
||||
-- 判据必须能被逐条钉住:这些模式的误判代价是把**可用模型藏 30 天**。
|
||||
function plugin.__is_model_gone_probe(args)
|
||||
|
||||
Reference in New Issue
Block a user