mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-09 18:57:21 +00:00
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>
401 lines
17 KiB
Lua
401 lines
17 KiB
Lua
-- 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==="&"?"&":c==="<"?"<":c===">"?">":""";
|
||
});
|
||
}
|
||
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 |