Files
ModelRouter/internal/lua/plugins/ccquota.lua
JianFeeeee b5f6cac0ef fix(plugins): ccquota 页面首次打开是空的
The plugin page rendered nothing until the operator clicked refresh, and even
the click handler was never wired — while every unit test was green and the
API returned correct numbers. Four separate mistakes stacked here:

1. The script waited on DOMContentLoaded, an event that had already been
   dispatched by the time the kernel injects a plugin mount. Replaced with
   pluginAPI.onTabShown (the kernel's own mechanism, fires with the DOM in
   place) PLUS an immediate call, because onTabShown alone leaves the page
   blank when the tab is already open at boot. The billing page does both; I had
   reduced it to one and broke first paint.

2. The re-entry guard flag lived in a closure variable. The tab's DOM is rebuilt
   on re-entry, so the handler was wired against a node that no longer existed.
   The flag now lives on the NODE (__ccqWired), which is what makes re-wiring
   correct.

3. refresh() swallowed its fetch failure and rendered "no sources", so a broken
   page and an empty account list looked identical. Failures now surface in the
   header line, including when pluginAPI.call is missing (an old kernel).

4. THE ACTUAL REASON the page stayed blank: there were TWO ccquota plugins. The
   embed (go:embed plugins/*.lua) version and the on-disk one both loaded, and
   the UI injected the EMBED copy — 5182 characters, predating every fix — while
   every edit I made and every hot reload went to the disk copy. The running
   binary was serving stale plugin code no amount of reloading could fix. Only a
   rebuild surfaced it, and only measuring the served <script> length (7053 vs
   5182) showed which copy the browser had.

Lesson worth keeping: "the file on disk is what runs" is false for anything
under go:embed. Compare what the SERVER sends before editing the same file
twice and concluding the edit did nothing.

Verified in the browser: 2 account cards, 4 window bars, real balances, no
overflow, no console errors, refresh button 48x29 and functional.

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
2026-10-04 08:23:55 +08:00

401 lines
17 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.
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
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_price = subs.data.metadata and nil
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)
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}
</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:"已超额"};
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||"正常"};
}
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">'+
win("5 小时", r.fiveHour)+win("每周", r.weekly)+
'</div>';
var meta=[];
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