feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权

插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。

UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。

覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。

过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。

角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。

不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。

有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。

插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。

roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。

**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。

写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。

readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。

readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。

去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。

生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit a6c6e6e5c7)
(cherry picked from commit 5eef5ad635)
This commit is contained in:
JianFeeeee
2026-10-04 10:51:07 +08:00
parent 2b34c7dc8a
commit 35b9422232
13 changed files with 2091 additions and 379 deletions

View File

@ -503,6 +503,24 @@ end
-- A whole page. The kernel injects this HTML and evaluates the <script> after
-- the DOM exists, and exposes `pluginAPI` for talking to the gateway.
plugin.ui = {
-- Readable by any role, because the state is SPLIT below: a user key sees
-- its own spend and nothing else. Marking the page admin-only would make the
-- split pointless; marking it "all" without the split is exactly the leak this
-- closes — a user key pulled the whole 18KB ledger (16871 requests, $8.86,
-- every source name) straight out of GET /state. The gateway's audience
-- override can tighten this per plugin without editing this file.
visibility = "all",
-- Split the state: global figures are deployment data and are withheld from
-- non-admin callers, while the per-key buckets describe ONE caller.
-- depth: by_key is {maskedKey: bucket}; by_day_key is {day: {maskedKey: …}}.
state_scope = {
global = {
"total", "by_source", "by_model", "by_day", "by_day_src",
"by_day_model", "by_tier_served", "skip_reasons", "degraded_reqs",
"unpriced_models", "unpriced_reqs", "started",
},
depth = { by_key = 1, by_day_key = 2 },
},
page = {
page_id = "billing",
title = "Billing",

View File

@ -1,368 +0,0 @@
-- 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

View File

@ -484,6 +484,11 @@ end
-- ---------------------------------------------------------------- page
PLUGIN.ui = {
-- Every row names an upstream host and what it has spent. That is deployment
-- information, not per-caller information, so it is admin-only by default; an
-- operator who wants a shared read-only view can widen it from config without
-- editing this file.
visibility = "admin",
page = {
page_id = "quota",
title = "Quota",