mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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>
(cherry picked from commit 111dc6d53f)
368 lines
16 KiB
Lua
368 lines
16 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){ 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 |