feat(plugins): ccquota 显示每月额度

The page showed a monthly BALANCE ($0.08 left) but no monthly ALLOWANCE, so
"how much do I get and how much of it is gone" had no answer — the most useful
number on a plan that is nearly exhausted.

The vendor's credits endpoint reports what is left and never the cap, so a
percentage is not derivable from it. Two things supply the missing half:

  - planId from subscriptions (here individual-goat), mapped to the caps the
    vendor publishes ($10 Go, $70 GOAT/Pro/Max).
  - totalMonthlyCredits from usage/summary, which counts over the BILLING PERIOD
    and so pairs with the monthly allowance rather than with the rolling 5h and
    weekly windows.

Because the cap is a table lookup and not an API fact, it is cross-checked
against monthly_used + balance: if the two disagree by more than 2% + $0.50,
the card says the figure may be off rather than confidently printing a wrong
percentage. A planId absent from the table renders the balance with no bar at
all — inventing a denominator would be worse than admitting there isn't one.

Also surfaces what the endpoints were already returning and the page was
dropping: period_end (with a countdown), cancel_at_period_end, plan name, and
the vendor's own belowThreshold flag, which is distinct from "balance > 0" —
an account can hold $3 and still be under their threshold.

Verified on production: both accounts show 每月 100% ($69.94/$70, red bar),
5 小时 0/14, 每周 0.803/35 on one; plan goat, 周期至 2026-10-17 / 2026-10-19.

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
JianFeeeee
2026-10-04 08:30:36 +08:00
parent b5f6cac0ef
commit 6bc3ff8bbd

View File

@ -46,6 +46,26 @@ local REQUIRED_HEADERS = {
-- key store). The kernel deliberately does NOT let a plugin read the config, so
-- this is the only channel — which also means the key never sits in Lua state
-- or in the persisted plugin file.
-- Monthly credit caps per planId.
--
-- The credits endpoint reports what is LEFT but never the cap, so a percentage
-- is not derivable from it alone. The caps below come from the vendor's own
-- pricing page: every plan costs $10/month and grants a different credit pool
-- ($10 Go, $70 GOAT, $70 Pro, $70 Max). totalMonthlyCredits + monthlyCredits
-- also sums to the cap, so this table is a CROSS-CHECK on the plan, not the only
-- source — if the two disagree, PLAN_CAPS_WAIVED shows it instead of silently
-- displaying a wrong percentage.
--
-- A planId absent from this table renders the balance with no percentage and no
-- bar. Guessing a cap for an unknown plan would be worse than showing none.
local PLAN_CAPS = {
["individual-go"] = 10,
["individual-goat"] = 70,
["individual-pro"] = 70,
["individual-max"] = 70,
["team-goat"] = 70,
}
local ACCOUNTS = {}
-- ---------------------------------------------------------------- helpers
@ -125,6 +145,11 @@ local function collectAccount(acct)
report.purchased = tonum(c.purchasedCredits)
report.free = tonum(c.freeCredits)
report.total = report.monthly + report.purchased + report.free
-- belowThreshold is the vendor's own "running low" flag, distinct from
-- whether any balance remains: an account can hold $3 and still be below
-- their configured threshold.
report.below_threshold = c.belowThreshold == true
report.threshold = tonum(c.creditThreshold)
report.limited = wl.limited == true
report.exceeded = wl.exceeded
report.fiveHour = wl.fiveHour
@ -137,7 +162,11 @@ local function collectAccount(acct)
report.failures[#report.failures + 1] = "subscriptions: " .. tostring(serr)
elseif type(subs) == "table" and type(subs.data) == "table" then
report.plan_status = subs.data.status
report.plan_price = subs.data.metadata and nil
report.plan_id = subs.data.planId
report.plan_cap = PLAN_CAPS[tostring(subs.data.planId or "")]
report.period_start = subs.data.currentPeriodStart
report.period_end = subs.data.currentPeriodEnd
report.cancel_at_period_end = subs.data.cancelAtPeriodEnd == true
local md = subs.data.metadata
if type(md) == "table" then
report.plan_name = md.plan or md.name or md.tier
@ -152,6 +181,23 @@ local function collectAccount(acct)
report.spent = tonum(usage.totalCost)
report.tokens = tonum(usage.totalTokens)
report.success_rate = tonum(usage.successRate)
-- usage/summary counts over the BILLING PERIOD, so this pairs with the
-- monthly allowance rather than with the rolling 5h/weekly windows.
report.monthly_used = tonum(usage.totalMonthlyCredits)
report.period_basis = usage.periodBasis
end
-- Cross-check the configured cap against what the vendor spent plus what is
-- left. A mismatch means PLAN_CAPS drifted from the real plan (a price change,
-- or a planId this table does not know) — surfaced rather than papered over,
-- because a confidently wrong "99% used" is the worst possible reading here.
if report.plan_cap and report.monthly_used and report.monthly_used > 0 then
local implied = report.monthly_used + report.total
local drift = math.abs(implied - report.plan_cap)
if drift > (report.plan_cap * 0.02 + 0.5) then
report.cap_mismatch = true
report.implied_cap = implied
end
end
-- An account whose every endpoint failed is an auth problem, not a data
@ -213,6 +259,8 @@ PLUGIN.ui = {
.ccq-meta{display:flex;gap:14px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:10px}
.ccq-fail{font-size:11.5px;color:#b91c1c;margin-top:8px;word-break:break-word}
.ccq-empty{opacity:.6;font-size:13px;padding:8px 0}
.ccq-win-nodat{opacity:.5}
.ccq-warn-line{font-size:11px;color:#b45309;margin-top:3px}
</style>
<script>
(function(){
@ -232,11 +280,41 @@ PLUGIN.ui = {
function stateOf(r){
if (r.auth_failed) return {cls:"err", txt:"认证失败"};
if (r.exceeded) return {cls:"out", txt:"已超额"};
if (r.below_threshold) return {cls:"low", txt:"低于阈值"};
var t = Number(r.total)||0;
if (t<=0) return {cls:"out", txt:"无余额"};
if (t<5) return {cls:"low", txt:"余额偏低"};
return {cls:"ok", txt:r.plan_status||"正常"};
}
// month renders the monthly allowance. Unlike the rolling windows this one is
// NOT capped by the vendor: credits reports what is left and usage reports what
// was spent, so the cap comes from the plan table (see PLAN_CAPS) and is
// cross-checked server-side. With no cap the balance is shown without a bar,
// because an invented denominator would be worse than none.
function month(r){
var used=Number(r.monthly_used)||0, cap=Number(r.plan_cap)||0;
var left=Number(r.total)||0;
var h='<div class="ccq-win"><div class="ccq-win-h"><span>每月';
if(r.period_end){
var d=Math.floor((new Date(r.period_end)-Date.now())/86400000);
if(d>0) h+=' <span class="ccq-reset" title="'+escH(r.period_end)+'">↻'+d+'d</span>';
}
h+='</span><span class="ccq-win-n">';
if(!(cap>0)){ h+='<span class="ccq-win-nodat" title="未知套餐额度">—</span></span>'; }
else{
var pctv=Math.floor(used/cap*100+0.5);
h+=pctv+'%</span></div>'+
'<div class="ccq-bar"><div class="ccq-bar-fill'+(pctv>=100?" over":(pctv>=80?" warn":""))+
'" style="width:'+Math.min(100,pctv)+'%"></div></div>'+
'<div class="ccq-win-d">$'+used.toFixed(2)+' / $'+cap.toFixed(0)+
' · 剩 '+usd(left)+'</div>';
if(r.cap_mismatch){
h+='</div><div class="ccq-warn-line" title="已用+剩余与套餐额度不符,比例仅供参考">⚠ 额度口径可能有变</div>';
} else { h+='</div>'; }
return h;
}
return h+'<div class="ccq-win-d">已用 $'+used.toFixed(2)+' · 剩 '+usd(left)+'</div></div>';
}
function win(label,w){
if(!w) return "";
var used=Number(w.used)||0, cap=Number(w.cap)||0;
@ -271,9 +349,12 @@ PLUGIN.ui = {
(Number(r.purchased)>0?'(含充值 $'+Number(r.purchased).toFixed(2)+')':'')+
'</span></div>'+
'<div class="ccq-grid">'+
win("5 小时", r.fiveHour)+win("每周", r.weekly)+
month(r)+win("5 小时", r.fiveHour)+win("每周", r.weekly)+
'</div>';
var meta=[];
if(r.plan_id) meta.push("套餐 "+r.plan_id.replace(/^individual-/,""));
if(r.cancel_at_period_end) meta.push("到期不续费");
if(r.period_end) meta.push("周期至 "+String(r.period_end).slice(0,10));
if(r.requests) meta.push(r.requests.toLocaleString()+" 次请求");
if(r.spent) meta.push("累计 "+usd(r.spent));
if(r.tokens) meta.push((r.tokens/1e9).toFixed(2)+"B tokens");