Files
ModelRouter/internal/lua/plugins/ccquota.lua
JianFeeeee 111dc6d53f feat(plugins): ccquota 多账户额度插件 + 页面→Lua 调用通道
A quota plugin cannot work on billing's model. Billing reports what the gateway
already measured, delivered on hooks during request forwarding. A plan balance
has no such arrival path: it exists only on the vendor's side, and the only way
to learn it is to ask — at the moment the operator clicks Refresh. No hook fires
on a click, and pluginAPI offered only state read/write.

So this adds the missing piece: POST /api/plugins/{name}/call/{fn}, plus
pluginAPI.call on the page side.

## Why the gate is an explicit export list

That endpoint is an HTTP surface into a Lua state. Without a declared allowlist
it would let any caller invoke the plugin's private helpers with crafted
arguments and read whatever it can see — for a plugin holding an API key, that
is handing the key to anyone who can POST. So a function is callable only if the
plugin listed it in plugin.exports. A plugin with no exports has no callable
surface; reading the missing list as "allow all" is exactly the failure mode
worth designing against. The endpoint is also admin-only.

## ccquota

One account per configured source on api.commandcode.ai, keyed by that source's
own key — two keys on one host are two seats, and both are shown. Reads
/alpha/whoami, /alpha/billing/credits, /alpha/billing/subscriptions and
/alpha/usage/summary. Each endpoint degrades independently: a vendor retiring
one route must not blank the others, and a key out of quota still reads credits
fine while inference 401s, so the report records WHICH part failed rather than
rendering a zero that reads as "no quota left".

The 401-is-everything trap: those endpoints need a bearer token plus three
headers. The token lives in the table now, per request. Passing the shared
header table straight through — my first version — yields 401 on every
endpoint, which is indistinguishable from a revoked key and sent me auditing the
account instead of the plugin.

## Also fixed: three real defects found by going live

- The bridge was registered as `httpGlobal` (the Go const's IDENTIFIER) instead
  of `__llmsproxy_http` (its VALUE). Green unit tests, "attempt to call global
  '__llmsproxy_http' (a nil value)" on first real request. Now a test boots a
  real plugin and calls it the way a plugin does.
- The call router kept `call/` in the function name, so every call 502'd with
  `does not export "call/collect_account"` while the exports list looked fine.
- readPluginExports used luaToJSON, which needs the `json` global — not yet
  installed while a plugin is registering — so it silently returned an empty
  list and every export looked undeclared. It uses luaTableToGoValue now.

Two tests hardcoded counts ("1 bundled plugin", "4 pages") and turned red the
moment a second bundled plugin existed. They now assert what they mean: billing
is among the bundled plugins, and the pages this test contributed all merged in
order. A count assertion is a changelog detector.

## Verified on production

  commandcode    2198972886@qq.com  active  $0.0811  5h 0/14  wk 0.803/35  44,921 req
  commmandcode2  jianf3112@gmail.com active  $0.0700  5h 0/14  wk 0/35      43,827 req

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

368 lines
16 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){ 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="拉取中…";
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){}
}
document.addEventListener("DOMContentLoaded",function(){
var b=document.getElementById("ccq-refresh");
if(b) b.addEventListener("click",refresh);
refresh();
});
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