Files
ModelRouter/internal/lua/plugins/ccquota.lua
JianFeeeee 6bc3ff8bbd 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>
2026-10-04 08:30:36 +08:00

482 lines
22 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- Command Code quota — multi-account plan balance for the gateway.
--
-- The billing plugin only ever reports what the gateway already measured, so it
-- never has to reach out. A plan balance is different: the number only exists
-- on the vendor's side, and the only way to learn it is to ask. Command Code
-- exposes /alpha/billing/credits (plus whoami / subscriptions / usage) on the
-- provider host, authenticated with the same API key the source already uses.
--
-- Accounts: one per configured source whose base_url is the Command Code API
-- host. Two keys on the same host are two separate accounts, and both are
-- shown — an operator with a spare seat needs to see both draining.
--
-- Every endpoint degrades independently. A vendor that retires one route must
-- not blank the others, and a key that has run out still returns HTTP 401 on
-- the inference path while credits reads fine, so the report has to survive
-- partial failure and say WHICH part failed rather than rendering a zero that
-- looks like "no quota left".
local PLUGIN = {}
PLUGIN.name = "ccquota"
PLUGIN.version = "1.0.0"
PLUGIN.description = "Command Code 多账户额度余量(5 小时 / 周 / 月窗口 + 余额)"
-- Command Code's API host. __llmsproxy_http only permits hosts that a
-- configured source already talks to, so this works exactly when such a source
-- exists — no separate allowlist to keep in sync, and no way to widen reach.
local API_HOST = "api.commandcode.ai"
-- These headers are required alongside the bearer token. Without them the
-- endpoints answer 401 even with a perfectly valid key, which looks identical
-- to a revoked key and costs a lot of time to tell apart.
--
-- The bearer token is NOT here on purpose: it is per-account, so fetchJSON
-- copies this table and injects it per request. An earlier version passed this
-- table straight through and every endpoint answered 401 — which reads exactly
-- like a revoked key, and sent me looking at the account instead of the plugin.
local CLI_VERSION = "1.54.2"
local REQUIRED_HEADERS = {
["x-command-code-version"] = CLI_VERSION,
["x-cli-environment"] = "production",
["User-Agent"] = "commandcode-quota/" .. CLI_VERSION,
}
-- Accounts are handed in from the page (which reads /api/sources and its own
-- 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
local function tonum(v)
return tonumber(v) or 0
end
-- usd formats a dollar amount the way the vendor's panel does: two decimals,
-- but never a bare "0" when there is a real fractional balance left. A $0.0811
-- balance rounds to $0.08, and showing "$0" there would read as "nothing left".
-- fetchJSON performs one request and returns (decodedTable, nil) or
-- (nil, errorMessage). It NEVER raises: an escaping error inside a plugin hook
-- can take the whole gateway down (see the hook-guard note in the kernel), so
-- every failure has to arrive as an ordinary value.
local function fetchJSON(path, key)
-- Copy, then add the token: mutating the shared table would leak one
-- account's key into every later request.
local headers = {}
for k, v in pairs(REQUIRED_HEADERS) do headers[k] = v end
headers["Authorization"] = "Bearer " .. tostring(key or "")
local res, err = __llmsproxy_http({
url = "https://" .. API_HOST .. path,
method = "GET",
headers = headers,
timeout_ms = 12000,
})
if res == nil then
return nil, tostring(err or "request failed")
end
-- Body arrives as a JSON string; the kernel's json.decode is the only parser
-- available and it raises on malformed input, so it is pcall'd here too.
local ok, decoded = pcall(json.decode, res.body)
if not ok then
return nil, "bad JSON from " .. path
end
return decoded, nil
end
-- authFailure recognises the one error that means "this key is not usable"
-- rather than "this endpoint is unhappy". Quota exhaustion and a revoked key
-- both surface as 401 here, and they need opposite operator responses.
-- ---------------------------------------------------------------- account report
-- collectAccount gathers one account's numbers. Each endpoint is independent:
-- credits is the one that matters, and the rest enrich it. A failure is
-- recorded per-endpoint instead of aborting, because "I could not read the
-- subscription" and "the account has no quota" must not look the same.
local function collectAccount(acct)
local report = {
name = acct.name,
label = acct.label,
failures = {},
checked_at = os.time(),
}
local who, werr = fetchJSON("/alpha/whoami", acct.key)
if who == nil then
report.failures[#report.failures + 1] = "whoami: " .. tostring(werr)
elseif type(who) == "table" and type(who.user) == "table" then
report.email = who.user.email
report.user = who.user.name or who.user.userName
end
local creds, cerr = fetchJSON("/alpha/billing/credits", acct.key)
local rawStatus = 0
if creds == nil then
report.failures[#report.failures + 1] = "credits: " .. tostring(cerr)
else
local c = creds.credits or {}
local wl = creds.windowLimits or {}
report.monthly = tonum(c.monthlyCredits)
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
report.weekly = wl.weekly
rawStatus = 200
end
local subs, serr = fetchJSON("/alpha/billing/subscriptions", acct.key)
if subs == nil then
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_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
end
end
local usage, uerr = fetchJSON("/alpha/usage/summary", acct.key)
if usage == nil then
report.failures[#report.failures + 1] = "usage: " .. tostring(uerr)
elseif type(usage) == "table" then
report.requests = tonum(usage.totalCount)
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
-- problem: state it plainly so nobody reads "—" as "zero quota".
if #report.failures >= 3 and report.total == nil then
report.auth_failed = true
end
return report
end
-- ---------------------------------------------------------------- page
PLUGIN.ui = {
page = {
page_id = "ccquota",
title = "CC Quota",
order = 45,
mount = [==[
<div id="ccq-root" class="ccq-root">
<div class="ccq-head">
<div>
<div class="ccq-title">Command Code 额度</div>
<div class="ccq-sub" id="ccq-sub">点击刷新以拉取各账户余量</div>
</div>
<div class="grow"></div>
<button class="small" id="ccq-refresh">刷新</button>
<span class="ccq-auto"></span>
</div>
<div id="ccq-list"><div class="ccq-empty">尚未拉取</div></div>
</div>
<style>
#ccq-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
.ccq-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
.ccq-title{font-size:15px;font-weight:600}
.ccq-sub{font-size:12px;opacity:.66;margin-top:2px}
.ccq-auto{font-size:12px;opacity:.6}
.ccq-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 12px;min-width:0}
.ccq-acc.err{border-color:rgba(220,80,80,.5)}
.ccq-acc-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
.ccq-acc-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}
.ccq-mail{font-size:12px;opacity:.66;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ccq-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
.ccq-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
.ccq-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
.ccq-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
.ccq-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 10px;flex-wrap:wrap}
.ccq-bal-n{font-size:20px;font-weight:600}
.ccq-bal-l{font-size:12px;opacity:.66}
.ccq-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 18px}
.ccq-win{min-width:0}
.ccq-win-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
.ccq-win-n{font-variant-numeric:tabular-nums;font-weight:600}
.ccq-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
.ccq-bar-fill{height:100%;background:#3b82f6;border-radius:3px}
.ccq-bar-fill.warn{background:#d97706}
.ccq-bar-fill.over{background:#dc2626}
.ccq-win-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.ccq-reset{opacity:.6;margin-left:4px}
.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(){
var HOST = "api.commandcode.ai";
var lastReports = [];
function escH(s){
return String(s==null?"":s).replace(/[&<>"]/g,function(c){
return c==="&"?"&amp;":c==="<"?"&lt;":c===">"?"&gt;":"&quot;";
});
}
function usd(v){
v = Number(v)||0;
if (v<=0) return "$0.00";
return "$"+(v<0.01?v.toFixed(4):v.toFixed(2));
}
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;
var p = cap>0 ? Math.floor(used/cap*100+0.5) : null;
if(p===null) return "";
var over = w.exceeded===true;
if(over) p=100;
var cls = "ccq-bar-fill"+(over||p>=100?" over":(p>=80?" warn":""));
var reset="";
if(Number(w.resetAt)>0){
var s=Math.floor(w.resetAt/1000-Date.now()/1000);
if(s>0){
var d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
reset=(d>0?d+"d "+h+"h":(h>0?h+"h "+m+"m":m+"m"));
}
}
return '<div class="ccq-win"><div class="ccq-win-h"><span>'+escH(label)+
(reset?' <span class="ccq-reset">↻'+escH(reset)+'</span>':'')+
'</span><span class="ccq-win-n">'+p+'%</span></div>'+
'<div class="ccq-bar"><div class="'+cls+'" style="width:'+p+'%"></div></div>'+
'<div class="ccq-win-d">'+used.toLocaleString()+' / '+cap.toLocaleString()+'</div></div>';
}
function card(r){
var st = stateOf(r);
var h = '<div class="ccq-acc'+((r.auth_failed||(r.failures&&r.failures.length===4))?" err":"")+'">'+
'<div class="ccq-acc-h"><span class="ccq-acc-n">'+escH(r.name||"")+'</span>'+
(r.email?'<span class="ccq-mail">'+escH(r.email)+'</span>':'')+
'<span class="grow"></span>'+
'<span class="ccq-pill '+st.cls+'">'+escH(st.txt)+'</span></div>'+
'<div class="ccq-bal"><span class="ccq-bal-n">'+usd(r.total)+
'</span><span class="ccq-bal-l">月度余额'+
(Number(r.purchased)>0?'(含充值 $'+Number(r.purchased).toFixed(2)+')':'')+
'</span></div>'+
'<div class="ccq-grid">'+
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");
if(r.success_rate) meta.push("成功率 "+r.success_rate.toFixed(2)+"%");
if(meta.length) h+='<div class="ccq-meta">'+meta.map(escH).join(" · ")+'</div>';
if(r.failures&&r.failures.length){
h+='<div class="ccq-fail">部分接口失败:'+r.failures.map(escH).join(";")+'</div>';
}
return h+"</div>";
}
function render(reports){
lastReports = reports||[];
var host=document.getElementById("ccq-list");
if(!host) return;
if(!lastReports.length){host.innerHTML='<div class="ccq-empty">没有可用的 Command Code 源</div>';return;}
host.innerHTML=lastReports.map(card).join("");
var sub=document.getElementById("ccq-sub");
if(sub) sub.textContent = lastReports.length+" 个账户 · "+
lastReports.filter(function(r){return r.auth_failed;}).length+" 个认证失败";
}
async function refresh(){
var srcs;
try{
var r=await fetch("/api/sources",{credentials:"same-origin"});
srcs=(await r.json()).sources||[];
}catch(e){
var s0=document.getElementById("ccq-sub"); if(s0) s0.textContent="拉取源列表失败: "+e.message;
render([]); return;
}
// Every source on the Command Code host is one account, keyed by its own
// API key — two keys on that host are two seats, and both must be listed.
var accts=srcs.filter(function(s){
try{ return new URL(s.base_url).hostname===HOST; }catch(e){ return false; }
});
if(!accts.length){ render([]); return; }
var btn=document.getElementById("ccq-refresh");
if(btn) btn.disabled=true;
var auto=document.getElementById("ccq-auto")||document.querySelector(".ccq-auto");
if(auto) auto.textContent="拉取中…";
if(!window.pluginAPI || typeof pluginAPI.call!=="function"){
var sb=document.getElementById("ccq-sub");
if(sb) sb.textContent="pluginAPI.call 不可用(内核版本过旧)";
render([]); return;
}
var out=[];
var errs=0;
// One account at a time: the endpoints are independent, and a sequential
// loop means a hung vendor endpoint delays only its own row instead of
// blocking the whole page behind one promise.
for (var i=0;i<accts.length;i++){
var a=accts[i];
try{
var rep = await pluginAPI.call("ccquota","collect_account",
{name:a.name,label:a.name,key:a.api_key});
out.push(rep||{name:a.name,failures:["no result"]});
}catch(e){
errs++;
out.push({name:a.name,failures:[String(e.message||e)],auth_failed:true});
}
}
render(out);
if(btn) btn.disabled=false;
if(auto) auto.textContent="更新于 "+new Date().toLocaleTimeString();
try{ await pluginAPI.postState("ccquota",{reports:out,updated_at:Date.now()}); }catch(e){}
}
// The mount is injected long after DOMContentLoaded has already fired, so
// listening for that event here registered a handler for an event that had
// already been dispatched — the page rendered but stayed empty until a manual
// refresh. Wiring the button and fetching immediately is what makes the page
// populate on first open.
// The plugin mount is injected by the kernel and its <script> evaluated after
// injection, but that still happens during the UI's own boot — and the page
// body is rebuilt whenever the tab is opened. Two consequences:
//
// - A DOMContentLoaded listener never fires (the event already passed), so
// the page rendered empty until a manual refresh.
// - Wiring the refresh button once at script-eval time silently loses the
// handler when the tab's DOM is replaced.
//
// pluginAPI.onTabShown is the kernel's answer to both: the callback runs when
// the tab is actually shown, with the DOM in place. It also runs once for the
// already-visible tab at boot, so this single registration covers first paint
// AND every later re-entry.
function mount(){
var b=document.getElementById("ccq-refresh");
// The tab's DOM is rebuilt on re-entry, so a handler wired against the old
// node is gone. The flag lives on the NODE, not in a closure variable, which
// is what makes re-wiring safe.
if(b && !b.__ccqWired){ b.__ccqWired = true; b.addEventListener("click",refresh); }
}
// Run once immediately — the billing page does the same, and waiting only for
// onTabShown leaves the page blank when the tab is already open at boot.
mount();
refresh();
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(mount);
window.__ccqRender = render;
window.__ccqRefresh = refresh;
})();
</script>
]==],
},
}
-- exports is what makes collect_account reachable from the plugin's own page.
-- Without this list the call endpoint refuses it: the endpoint is an HTTP
-- surface into the Lua state, so "which functions may it reach" has to be the
-- plugin's explicit answer rather than a default.
PLUGIN.exports = { "collect_account" }
-- collect_account gathers one account's report. It returns the report TABLE (not
-- a string) so the page receives structured numbers and booleans instead of
-- having to parse anything back out of a formatted blob.
PLUGIN.collect_account = function(acct)
if type(acct) ~= "table" then return nil, "expected account table" end
if acct.key == nil or acct.key == "" then
return { name = tostring(acct.name or ""), failures = { "missing api key" },
auth_failed = true }
end
local rep = collectAccount({
name = acct.name, label = acct.label, key = acct.key,
})
return rep, nil
end
return PLUGIN