local PLUGIN = {}
PLUGIN.name = "quota"
PLUGIN.version = "1.0.0"
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
PLUGIN.exports = { "collect_source", "collect_all", "due_sources", "memo_record",
"resolve_api_base" }
PLUGIN.state = {}
local function tonum(v)
return tonumber(v) or 0
end
local function hostOf(u)
return (string.match(u, "^https?://([^/]+)")) or u
end
local function get(root, path)
if root == nil then return nil end
local cur = root
for seg in string.gmatch(path, "[^%.]+") do
if type(cur) ~= "table" then return nil end
cur = cur[seg]
if cur == nil then return nil end
end
return cur
end
local function getnum(root, path)
local v = get(root, path)
if v == nil then return nil end
if type(v) == "boolean" then return v and 1 or 0 end
local n = tonumber(v)
return n
end
local function strang(root, path)
local v = get(root, path)
if v == nil then return nil end
if type(v) ~= "string" then return nil end
if v == "" then return nil end
return v
end
local function request(path, key, headers, tries)
tries = tries or 2
local hdrs = {}
for k, v in pairs(headers or {}) do hdrs[k] = v end
hdrs["Authorization"] = "Bearer " .. tostring(key or "")
local lastErr = "no attempt"
for _ = 1, tries do
local res, err = __llmsproxy_http({
url = path, method = "GET", headers = hdrs, timeout_ms = 4000,
})
if res ~= nil then
if res.status >= 200 and res.status < 300 then
local ok, decoded = pcall(json.decode, res.body or "")
if ok and type(decoded) == "table" then
return decoded, nil
end
return nil, "not json (HTTP " .. tostring(res.status) .. ")"
end
if res.status == 401 or res.status == 403 then
return nil, "HTTP " .. tostring(res.status) .. " (key rejected)"
end
lastErr = "HTTP " .. tostring(res.status)
else
lastErr = tostring(err or "request failed")
end
end
return nil, lastErr
end
local PLAN_CAPS = {
["individual-go"] = 10,
["individual-goat"] = 70,
["individual-pro"] = 70,
["individual-max"] = 70,
["team-goat"] = 70,
}
local function deepseekBalance(base, key)
local r, err = request(base .. "/user/balance", key)
if r == nil then
if err and (err:match("not json") or err:match("HTTP 404")) then
return nil, "skip", err
end
return nil, "error", err
end
local infos = get(r, "balance_infos")
if type(infos) ~= "table" or #infos == 0 then return nil, "skip", "no balance_infos" end
local first = infos[1]
local rep = {
vendor = "deepseek-style",
currency = tostring(first.currency or "USD"),
balance = tonum(first.total_balance),
granted = getnum(first, "granted_balance"),
topped_up = getnum(first, "topped_up_balance"),
available = get(r, "is_available") == true,
}
return rep, nil
end
local function openaiDashboard(base, key)
local sub, subErr = request(base .. "/v1/dashboard/billing/subscription", key, nil, 1)
if sub == nil then
if subErr and (subErr:match("not json") or subErr:match("HTTP 404")) then
return nil, "skip", subErr
end
end
local use, useErr = request(base .. "/v1/dashboard/billing/usage", key, nil, 1)
if sub == nil and use == nil then
if subErr and subErr:match("key rejected") then
return nil, "error", subErr
end
return nil, "skip", (subErr or useErr or "no dashboard")
end
local rep = {
vendor = "openai-dashboard",
hard_limit = getnum(sub or {}, "hard_limit_usd"),
soft_limit = getnum(sub or {}, "soft_limit_usd"),
total_usage = getnum(use or {}, "total_usage"),
has_payment_method = get(sub or {}, "has_payment_method") == true,
}
rep.balance = nil
if rep.hard_limit and rep.hard_limit < 1000000 and rep.total_usage then
rep.balance = rep.hard_limit - rep.total_usage
rep.cap = rep.hard_limit
rep.used = rep.total_usage
end
return rep, nil
end
local function copySet(t)
local out = nil
for k, v in pairs(t) do
out = out or {}
out[k] = v
end
return out
end
local function provisionalEntry(entry)
if type(entry) ~= "table" then return false end
if type(entry.unready) ~= "table" then return false end
return next(entry.unready) ~= nil
end
local function vendorReady(vendor)
if vendor == "opendesign" then
local c = PLUGIN.prices
if type(c) ~= "table" or type(c.control_key) ~= "string" or c.control_key == "" then
return false, "control_key not configured (set prices.control_key)"
end
end
return true, nil
end
local function resolveApiBase(base, override)
if type(override) == "string" and override ~= "" then
return (override:gsub("/+$", ""))
end
local origin = base:gsub("^https?://", ""):gsub("/.*$", "")
origin = origin:gsub("^amr%-link%.open%-design%.ai$", "amr-api.open-design.ai")
return "https://" .. origin .. "/api/v1"
end
PLUGIN.resolve_api_base = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
local base = tostring(payload.base_url or "")
if base == "" then return nil, "missing base_url" end
local override = payload.api_base
if type(override) ~= "string" then override = nil end
return { api_base = resolveApiBase(base, override) }, nil
end
local function openDesign(base, key)
local cfg = PLUGIN.prices
local ck = nil
if type(cfg) == "table" then ck = cfg.control_key end
if type(ck) ~= "string" or ck == "" then
return nil, "skip", "control_key not configured (set prices.control_key)"
end
local api = resolveApiBase(base, type(cfg) == "table" and cfg.api_base or nil)
local wallet, wErr = request(api .. "/wallet/balance", ck, nil, 1)
local summary, sErr = request(api .. "/billing/summary", ck, nil, 1)
if wallet == nil and summary == nil then
if (wErr or ""):match("key rejected") or (sErr or ""):match("key rejected") then
return nil, "error", "control_key rejected (HTTP 401)"
end
if (wErr or ""):match("HTTP 404") and (sErr or ""):match("HTTP 404") then
return nil, "skip", "no OpenDesign billing endpoints"
end
return nil, "skip", (wErr or sErr or "no billing response")
end
local w = wallet or {}
local b = summary or {}
local rep = {
vendor = "opendesign",
balance = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"),
balance_usd = getnum(w, "balanceUsd") or getnum(b, "balanceUsd"),
tier = strang(b, "membershipTier"),
status = strang(b, "subscriptionStatus"),
interval = strang(b, "billingInterval"),
}
local rate = getnum(b, "creditsPerUsd")
if rate and rate > 0 then
rep.credits_per_usd = rate
local sub = getnum(b, "subscriptionCredits")
local rec = getnum(b, "rechargeCredits")
if sub or rec then
rep.credits = (sub or 0) + (rec or 0)
rep.subscription_usd = sub and (sub / rate) or nil
rep.recharge_usd = rec and (rec / rate) or nil
end
rep.total_available_credits = getnum(b, "totalAvailableCredits")
else
rep.credits_rate_missing = true
end
local sUsd = getnum(b, "subscriptionCreditsUsd")
local rUsd = getnum(b, "rechargeCreditsUsd")
if sUsd then rep.subscription_usd = sUsd end
if rUsd then rep.recharge_usd = rUsd end
rep.today_used = getnum(b, "todayConsumedUsd")
rep.period_used = getnum(b, "totalConsumedUsd")
rep.total_recharged = getnum(b, "totalRechargedUsd")
rep.usage_count = getnum(b, "usageCount")
rep.balance_expires_at = strang(w, "expiresAt")
rep.period_start_at = strang(b, "subscriptionCurrentPeriodStart")
rep.period_end_at = strang(b, "subscriptionCurrentPeriodEnd")
rep.workspace_id = strang(w, "workspaceId")
if rep.balance == nil then
return nil, "skip", "no balanceUsd in response"
end
return rep, nil
end
local function commandCode(base, key)
local H = {
["x-command-code-version"] = "1.54.2",
["x-cli-environment"] = "production",
["User-Agent"] = "quota-plugin/1.0.0",
}
local origin = base:gsub("/provider/v1/?$", "")
local credits, err = request(origin .. "/alpha/billing/credits", key, H)
if credits == nil then
if err and err:match("key rejected") then return nil, "error", err end
return nil, "skip", err
end
local rep = {
vendor = "commandcode",
currency = "USD",
balance = tonum(get(credits, "credits.monthlyCredits"))
+ tonum(get(credits, "credits.purchasedCredits"))
+ tonum(get(credits, "credits.freeCredits")),
below_threshold = get(credits, "credits.belowThreshold") == true,
limited = get(credits, "windowLimits.limited") == true,
exceeded = get(credits, "windowLimits.exceeded"),
}
local fh = get(credits, "windowLimits.fiveHour")
if type(fh) == "table" then
rep.windows = rep.windows or {}
rep.windows[#rep.windows + 1] = {
label = "5 小时", used = tonum(fh.used), cap = tonum(fh.cap),
exceeded = fh.exceeded == true, reset_at = tonum(fh.resetAt),
}
end
local wk = get(credits, "windowLimits.weekly")
if type(wk) == "table" then
rep.windows = rep.windows or {}
rep.windows[#rep.windows + 1] = {
label = "每周", used = tonum(wk.used), cap = tonum(wk.cap),
exceeded = wk.exceeded == true, reset_at = tonum(wk.resetAt),
}
end
local subs = request(origin .. "/alpha/billing/subscriptions", key, H, 1)
if subs ~= nil then
rep.plan_id = get(subs, "data.planId")
rep.plan_status = get(subs, "data.status")
rep.period_end = get(subs, "data.currentPeriodEnd")
rep.plan_cap = PLAN_CAPS[tostring(rep.plan_id or "")]
rep.cancel_at_period_end = get(subs, "data.cancelAtPeriodEnd") == true
end
local usage = request(origin .. "/alpha/usage/summary", key, H, 1)
if usage ~= nil then
rep.monthly_used = getnum(usage, "totalMonthlyCredits")
rep.requests = getnum(usage, "totalCount")
rep.tokens = getnum(usage, "totalTokens")
rep.success_rate = getnum(usage, "successRate")
end
if rep.plan_cap and rep.monthly_used and rep.monthly_used > 0 then
local implied = rep.monthly_used + rep.balance
if math.abs(implied - rep.plan_cap) > (rep.plan_cap * 0.02 + 0.5) then
rep.cap_mismatch = true
rep.implied_cap = implied
end
end
return rep, nil
end
local ADAPTERS = {
{ vendor = "commandcode", probe = commandCode },
{ vendor = "deepseek", probe = deepseekBalance },
{ vendor = "openai-dash", probe = openaiDashboard },
{ vendor = "opendesign", probe = openDesign },
}
local HOST_MEMO = {}
local HOST_MEMO_AT = {}
local PROBE_MEMO_TTL = 1800
local BACKOFF_BASE = 1800
local BACKOFF_FACTOR = 2
local BACKOFF_MAX = 21600
local function backoffSeconds(at, streak)
if streak == nil or streak <= 0 then return false end
local wait = BACKOFF_BASE
for _ = 2, streak do
wait = wait * BACKOFF_FACTOR
if wait >= BACKOFF_MAX then wait = BACKOFF_MAX break end
end
return (os.time() - (at or 0)) < wait
end
local PROBE_BUDGET_MS = 12000
local function collectSource(src, budget)
local base = tostring(src.base_url or "")
if base == "" then return nil end
base = base:gsub("/+$", "")
local key = tostring(src.api_key or "")
local rep, kind, why
local unready = {}
local errWhy, errRejected = nil, false
local host = hostOf(base)
local known = HOST_MEMO[host]
if type(known) == "table" then
local seen = known.at or 0
local provisional = provisionalEntry(known)
local stale = false
if provisional then
for vendor in pairs(known.unready) do
if vendorReady(vendor) then stale = true end
end
end
if provisional then
if stale then
known = nil
else
known = false
end
else
if backoffSeconds(seen, known.streak) then
return { name = src.name, host = host, skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
end
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
known = nil
else
known = false
end
end
end
if known == false then
local probe = __llmsproxy_http({
url = base .. "/", method = "GET", timeout_ms = 2500,
headers = { ["Authorization"] = "Bearer " .. tostring(key or "") },
})
if probe == nil then
return { name = src.name, host = host, skipped = true,
reason = "host unreachable" }
end
end
local order = ADAPTERS
if known then
local reordered, seen = {}, {}
for _, a in ipairs(ADAPTERS) do
if a.vendor == known then
reordered[#reordered + 1] = a
seen[a.vendor] = true
end
end
for _, a in ipairs(ADAPTERS) do
if not seen[a.vendor] then reordered[#reordered + 1] = a end
end
order = reordered
end
for _, a in ipairs(order) do
if budget and budget.exhausted() then
return { name = src.name, host = host, skipped = true,
reason = "probe budget exhausted" }
end
if not vendorReady(a.vendor) then
local _, because = vendorReady(a.vendor)
unready[a.vendor] = true
kind = "skip"
why = because or ("adapter not configured: " .. a.vendor)
else
local r, k, e = a.probe(base, key)
if r ~= nil then
rep = r
HOST_MEMO[host] = a.vendor
break
end
kind, why = k, e
if k == "error" then
local rejected = tostring(e or ""):match("rejected") ~= nil
if not errRejected and (errWhy == nil or rejected) then
errWhy, errRejected = e, rejected
end
end
end
end
if rep == nil then
local prev = HOST_MEMO[host]
local streak = 1
local prevUnready = provisionalEntry(prev)
if type(prev) == "table" and not prevUnready then
streak = (prev.streak or 1) + 1
end
HOST_MEMO[host] = { at = os.time(), streak = streak, unready = copySet(unready) }
end
if rep ~= nil then
HOST_MEMO_AT[host] = os.time()
end
if rep == nil then
if kind == "error" or errRejected then
return { name = src.name, vendor = "", error = tostring(errWhy or why),
skipped = false, host = hostOf(base) }
end
local out = { name = src.name, skipped = true, host = hostOf(base) }
if why and why ~= "" then out.reason = tostring(why) end
if next(unready) ~= nil then out.unready = true end
return out
end
rep.name = src.name
rep.host = hostOf(base)
rep.skipped = false
rep.checked_at = os.time()
return rep
end
PLUGIN.memo_record = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
if not next(HOST_MEMO) then
local st = PLUGIN.state
if type(st) == "table" and type(st.probe_memo) == "table" then
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
end
end
local rows = payload.results
if type(rows) ~= "table" then return nil, "missing results" end
local now = os.time()
for _, r in ipairs(rows) do
if type(r) == "table" and type(r.host) == "string" and r.host ~= "" then
local host = hostOf(r.host)
if r.vendor then
HOST_MEMO[host] = r.vendor
else
local prev = HOST_MEMO[host]
local streak = 1
if type(prev) == "table" and not prev.unready then
streak = (prev.streak or 1) + 1
end
local u = nil
if r.unready then
for _, a in ipairs(ADAPTERS) do
if not vendorReady(a.vendor) then
u = u or {}
u[a.vendor] = true
end
end
end
HOST_MEMO[host] = { at = now, streak = streak, unready = u }
end
end
end
PLUGIN.state.probe_memo = HOST_MEMO
return true
end
PLUGIN.due_sources = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
if not next(HOST_MEMO) then
local st = PLUGIN.state
if type(st) == "table" and type(st.probe_memo) == "table" then
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
end
end
local srcs = payload.sources
if type(srcs) ~= "table" then return nil, "missing sources" end
local due, backing = {}, {}
for _, s in ipairs(srcs) do
if type(s) == "table" and type(s.base_url) == "string" and s.base_url ~= "" then
local base = (s.base_url:gsub("/+$", ""))
local known = HOST_MEMO[hostOf(base)]
if type(known) == "table" and backoffSeconds(known.at or 0, known.streak) then
backing[#backing + 1] = { name = s.name, host = hostOf(base), skipped = true, backing_off = true,
reason = "连续探测失败 " .. tostring(known.streak) .. " 次,退避中(等待下次重试)" }
else
due[#due + 1] = s
end
end
end
return { due = due, backing_off = backing }
end
PLUGIN.collect_all = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
if not next(HOST_MEMO) then
local st = PLUGIN.state
if type(st) == "table" and type(st.probe_memo) == "table" then
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
end
end
local srcs = payload.sources
if type(srcs) ~= "table" then return nil, "missing sources" end
local t1 = os.time()
local lim1 = PROBE_BUDGET_MS / 1000
local function spent()
return (os.time() - t1) > lim1
end
local budget = { exhausted = spent }
local out = {}
for _, s in ipairs(srcs) do
if type(s) == "table" then
local rep = collectSource(s, budget)
if rep ~= nil then out[#out + 1] = rep end
end
end
PLUGIN.state.probe_memo = HOST_MEMO
return out, nil
end
PLUGIN.collect_source = function(payload)
if type(payload) ~= "table" then return nil, "expected table" end
if not next(HOST_MEMO) then
local st = PLUGIN.state
if type(st) == "table" and type(st.probe_memo) == "table" then
for k, v in pairs(st.probe_memo) do HOST_MEMO[k] = v end
end
end
local srcs = payload.sources
if type(srcs) ~= "table" then return nil, "missing sources" end
local t0 = os.time()
local limit = PROBE_BUDGET_MS / 1000
local function exhausted()
return (os.time() - t0) > limit
end
local budget = { exhausted = exhausted }
for _, s in ipairs(srcs) do
if type(s) == "table" and s.name == payload.name then
local rep = collectSource(s, budget)
PLUGIN.state.probe_memo = HOST_MEMO
return rep, nil
end
end
return nil, "source not found: " .. tostring(payload.name)
end
PLUGIN.ui = {
visibility = "admin",
page = {
page_id = "quota",
title = "Quota",
icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="7" width="16" height="10" rx="2.4"/><path d="M21.5 10.5v3"/><path d="M6 10.5v3M10 10.5v3M14 10.5v3"/></svg>]==],
order = 45,
mount = [==[
<div id="qa-root" class="qa-root">
<div class="qa-head">
<div>
<div class="qa-title">套餐余量</div>
<div class="qa-sub" id="qa-sub">点击刷新</div>
</div>
<div class="grow"></div>
<button class="small" id="qa-refresh">刷新</button>
</div>
<div id="qa-list"><div class="qa-empty">尚未拉取</div></div>
</div>
<style>
#qa-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
.qa-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
.qa-title{font-size:15px;font-weight:600}
.qa-sub{font-size:12px;opacity:.66;margin-top:2px}
.qa-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 10px;min-width:0}
.qa-acc.err{border-color:rgba(220,80,80,.5)}
.qa-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
.qa-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%}
.qa-host{font-size:11.5px;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.qa-vendor{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.8;white-space:nowrap}
.qa-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
.qa-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
.qa-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
.qa-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
.qa-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 8px;flex-wrap:wrap}
.qa-bal-n{font-size:19px;font-weight:600}
.qa-bal-l{font-size:12px;opacity:.66}
.qa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:9px 18px}
.qa-w{min-width:0}
.qa-w-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
.qa-w-n{font-variant-numeric:tabular-nums;font-weight:600}
.qa-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
.qa-fill{height:100%;background:#3b82f6;border-radius:3px}
.qa-fill.warn{background:#d97706}
.qa-fill.over{background:#dc2626}
.qa-w-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.qa-meta{display:flex;gap:13px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:9px}
.qa-fail{font-size:11.5px;color:#b91c1c;margin-top:7px;word-break:break-word}
.qa-warnl{font-size:11px;color:#b45309;margin-top:3px}
.qa-empty{opacity:.6;font-size:13px;padding:8px 0}
.qa-skip{font-size:11.5px;opacity:.5;margin-top:8px;line-height:1.6}
</style>
<script>
(function(){
var MONTHLY_CAP_NOTE = "月度上限按套餐表推导";
function escH(s){return String(s==null?"":s).replace(/[&<>"]/g,function(c){
return c==="&"?"&":c==="<"?"<":c===">"?">":""";});}
// Money is formatted with the vendor's own currency; CNY and USD differ only
// in symbol here, which is deliberate — guessing an exchange rate would be a
// far worse lie than showing the raw number with its unit.
function money(v, cur){
cur = cur || "USD";
var sym = cur === "CNY" ? "¥" : (cur === "USD" ? "$" : cur + " ");
v = Number(v)||0;
// A negative credit is a real debit, not "zero" — deepseek reports
// topped_up_balance -0.02 against a grant, and flattening that to 0.00
// hides the overdraft.
if (v===0) return sym+"0.00";
if (v<0) return "-"+sym+Math.abs(v).toFixed(2);
return sym+(v<0.01?v.toFixed(4):v.toFixed(2));
}
function pct(u,c){ c=Number(c)||0; u=Number(u)||0; if(!(c>0)) return null;
return Math.floor(u/c*100+0.5); }
function resetTxt(ms){
if(!(Number(ms)>0)) return "";
var s=Math.floor(Number(ms)/1000-Date.now()/1000); if(s<=0) return "";
var d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
if(d>0) return d+"d "+h+"h"; if(h>0) return h+"h "+m+"m"; return m+"m";
}
// windowRow renders one used/cap bar. A window with no cap renders as a plain
// "used" line rather than a bar of unknown width.
function win(w){
if(!w) return "";
var cap=Number(w.cap)||0, used=Number(w.used)||0;
var h='<div class="qa-w"><div class="qa-w-h"><span>'+escH(w.label);
var r=resetTxt(w.reset_at); if(r) h+=' <span style="opacity:.6">↻'+escH(r)+'</span>';
var p = pct(used,cap);
if(p===null){
h+='</span><span class="qa-w-n">'+used.toLocaleString()+'</span></div>'+
'<div class="qa-w-d">已用(无上限数据)</div></div>';
return h;
}
if(w.exceeded===true) p=100;
var cls="qa-fill"+((w.exceeded===true||p>=100)?" over":(p>=80?" warn":""));
h+='</span><span class="qa-w-n">'+p+'%</span></div>'+
'<div class="qa-bar"><div class="'+cls+'" style="width:'+Math.min(100,p)+'%"></div></div>'+
'<div class="qa-w-d">'+used.toLocaleString()+' / '+cap.toLocaleString()+'</div></div>';
return h;
}
// monthRow is Command Code's monthly allowance, which has no vendor cap.
function month(r){
var cap=Number(r.plan_cap)||0, used=Number(r.monthly_used)||0;
if(!(used>0) && !(cap>0)) return "";
var h='<div class="qa-w"><div class="qa-w-h"><span>每月';
var pe=r.period_end;
if(pe){ var d=Math.floor((new Date(pe)-Date.now())/86400000);
if(d>0) h+=' <span style="opacity:.6">↻'+d+'d</span>'; }
var p=pct(used,cap);
if(p===null){
h+='</span><span class="qa-w-n">$'+used.toFixed(2)+'</span></div>'+
'<div class="qa-w-d" title="'+escH(MONTHLY_CAP_NOTE)+'">已用 · 套餐上限未知</div></div>';
return h;
}
h+='</span><span class="qa-w-n">'+p+'%</span></div>'+
'<div class="qa-bar"><div class="qa-fill'+(p>=100?" over":(p>=80?" warn":""))+
'" style="width:'+Math.min(100,p)+'%"></div></div>'+
'<div class="qa-w-d">$'+used.toFixed(2)+' / $'+cap.toFixed(0)+'</div>';
if(r.cap_mismatch){
h+='<div class="qa-warnl" title="已用+余额 与套餐额度不符,比例仅供参考">注意 额度口径可能有变</div>';
}
return h+"</div>";
}
// balanceLine renders the headline number. Three cases, kept visually distinct
// because they mean different things: money left, a spend against a ceiling,
// or no number at all.
function balLine(r){
if(r.error) return "";
if(r.balance!==undefined && r.balance!==null){
var extra="";
// granted / topped_up may be NEGATIVE (deepseek reports topped_up -0.02
// against a positive grant), so the zero test is on the ABSOLUTE value.
// `!== 0` on a string-typed value would have been true anyway, but on a
// coerced number it hid a real debit behind a flat 0.00.
var g=Number(r.granted), t2=Number(r.topped_up);
if((!isNaN(g)&&g!==0)||(!isNaN(t2)&&t2!==0)){
extra=" <span class='qa-bal-l'>(赠送 "+
money(g,r.currency)+" · 充值 "+money(t2,r.currency)+")</span>";
}
var pill="";
if(r.available===false) pill=' <span class="qa-pill out">不可用</span>';
else if(r.exceeded) pill=' <span class="qa-pill out">已超额</span>';
else if(r.below_threshold) pill=' <span class="qa-pill low">低于阈值</span>';
return '<div class="qa-bal"><span class="qa-bal-n">'+money(r.balance,r.currency)+
'</span><span class="qa-bal-l">余额</span>'+pill+extra+'</div>';
}
if(r.hard_limit!==undefined && r.hard_limit!==null){
// No balance is derivable: a 1e8 ceiling is a policy number, not money.
var s='<div class="qa-bal"><span class="qa-bal-n">'+money(r.total_usage||0,r.currency)+
'</span><span class="qa-bal-l">累计花费(限额 '+
(r.hard_limit>=1000000?'∞':money(r.hard_limit,r.currency))+')</span></div>';
return s;
}
return "";
}
function state(r){
if(r.error) return {cls:"err", pill:'<span class="qa-pill out">读取失败</span>'};
if(r.available===false) return {cls:"out", pill:'<span class="qa-pill out">不可用</span>'};
if(r.exceeded) return {cls:"out", pill:'<span class="qa-pill out">已超额</span>'};
if(r.below_threshold) return {cls:"low", pill:'<span class="qa-pill low">低于阈值</span>'};
if(r.balance!==undefined && r.balance!==null){
var b=Number(r.balance);
if(b<=0) return {cls:"out", pill:'<span class="qa-pill out">无余额</span>'};
if(b<5) return {cls:"low", pill:'<span class="qa-pill low">余额偏低</span>'};
return {cls:"ok", pill:'<span class="qa-pill ok">'+(r.plan_status||"正常")+'</span>'};
}
if(r.hard_limit!==undefined && r.hard_limit!==null)
return {cls:"ok", pill:'<span class="qa-pill ok">按量计费</span>'};
// Nothing here matched. Say so.
//
// The old default was a neutral "有数据", which is a claim: it says the probe
// answered. A record that reached this point answered with no field the page
// knows — a back-off notice, an error shape we do not recognise, or a vendor
// that added a new response. Calling that "has data" is how thirteen dead
// upstreams ended up rendered as healthy. Absence of evidence is not evidence.
return {cls:"unknown", pill:'<span class="qa-pill">未探测</span>'};
}
function card(r){
var st=state(r);
var h='<div class="qa-acc'+(r.error?" err":"")+'"><div class="qa-h">'+
'<span class="qa-n">'+escH(r.name)+'</span>'+
'<span class="qa-host">'+escH(r.host||"")+'</span>'+
'<span class="qa-vendor">'+escH(r.vendor||"?")+'</span>'+
'<span class="grow"></span>'+st.pill+'</div>';
if(r.error){
h+='<div class="qa-fail">'+escH(r.error)+'</div></div>';
return h;
}
// A record with nothing but a reason has no balance, no month, no windows —
// the card renders as a bare header with an empty body. Say why instead.
if(r.reason && !r.error && (r.balance===undefined||r.balance===null)
&& (r.hard_limit===undefined||r.hard_limit===null)){
h+='<div class="qa-fail">'+escH(r.reason)+'</div>';
return h+'</div>';
}
h+=balLine(r);
var rows="";
rows+=month(r);
if(r.windows) r.windows.forEach(function(w){ rows+=win(w); });
if(rows) h+='<div class="qa-grid">'+rows+'</div>';
var meta=[];
if(r.plan_id) meta.push("套餐 "+String(r.plan_id).replace(/^individual-/,""));
if(r.period_end) meta.push("周期至 "+String(r.period_end).slice(0,10));
if(r.cancel_at_period_end) meta.push("到期不续费");
if(r.requests) meta.push(r.requests.toLocaleString()+" 次请求");
if(r.tokens) meta.push((r.tokens/1e9).toFixed(2)+"B tokens");
if(r.success_rate) meta.push("成功率 "+r.success_rate.toFixed(2)+"%");
if(r.has_payment_method===false) meta.push("未绑定支付方式");
if(meta.length) h+='<div class="qa-meta">'+meta.map(escH).join(" · ")+'</div>';
return h+"</div>";
}
// paintSnapshot renders the persisted snapshot WITHOUT touching the network.
// Called on load so the page has content immediately.
async function showSnapshot(){
try{
var st = await pluginAPI.fetchState("quota");
var rs = (st && st.reports) || [];
if(!rs.length) return false;
var real = rs.filter(function(r){ return r && !r.skipped; });
// Records, not names — same reason as the refresh path. Both call sites were
// mapping to names while render() was changed to group on the flag; fixing
// one and shipping it is what produced a footer with thirteen blank entries.
var skipped = rs.filter(function(r){ return r && r.skipped; });
render(real, skipped);
var sub=document.getElementById("qa-sub");
if(sub && st.updated_at){
sub.textContent = real.length+" 个源 · 快照 "+new Date(st.updated_at).toLocaleTimeString()+"(点刷新取实时)";
}
return true;
}catch(e){ return false; }
}
function render(reports, skipped){
var host=document.getElementById("qa-list"); if(!host) return;
var real=(reports||[]).filter(function(r){return !r.skipped;});
var bad=real.filter(function(r){return r.error;});
if(!real.length){
host.innerHTML='<div class="qa-empty">没有源提供可读的余额接口</div>';
} else {
host.innerHTML=real.map(card).join("");
if(skipped && skipped.length){
// Two reasons a source has no card, and they are not interchangeable.
// "This upstream exposes no balance endpoint" and "this upstream is
// backing off after failing" are different facts; collapsing them into
// one list tells the operator their dead host simply has no dashboard.
var backing = skipped.filter(function(r){ return r.backing_off===true; });
var silent = skipped.filter(function(r){ return r.backing_off!==true; });
if(backing.length){
host.innerHTML+='<div class="qa-skip">'+backing.length+
' 个源正在退避(连续探测失败,等下次重试):'+escH(backing.map(function(r){return r.name;}).join("、"))+'</div>';
}
if(silent.length){
// Name the reason per source instead of assuming every silent skip
// means "no balance endpoint". A source that skipped because its
// credential is missing is listed the same way but needs a
// completely different fix, and an operator reading a flat list of
// names has no way to tell them apart.
var why = silent.map(function(r){
return r.reason ? (r.name+"("+r.reason+")") : r.name;
}).join("、");
host.innerHTML+='<div class="qa-skip">未列出 '+silent.length+
' 个源:'+escH(why)+'</div>';
}
}
}
var sub=document.getElementById("qa-sub");
if(sub) sub.textContent = real.length+" 个源 · "+bad.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("qa-sub"); if(s0) s0.textContent="拉取源列表失败: "+e.message;
return;
}
if(!window.pluginAPI || typeof pluginAPI.call!=="function"){
var sb=document.getElementById("qa-sub"); if(sb) sb.textContent="pluginAPI.call 不可用(内核版本过旧)";
return;
}
var btn=document.getElementById("qa-refresh"); if(btn) btn.disabled=true;
var sub=document.getElementById("qa-sub"); if(sub) sub.textContent="探测中…";
var out;
try{
// ONE request, fanned out inside the gateway.
//
// The previous version fired Promise.all at /call/ — twenty concurrent
// browser requests. That looked concurrent and was not: each one queued on
// the plugin mutex for the whole Lua call, network wait included. Measured on
// the live gateway: 29s of wall clock for twenty probes, against 1.4–3.3s for
// a single probe with no contention. The page sat on "探测中…" for half a
// minute and read as broken.
//
// The comment that used to sit here claimed concurrency avoided a call budget
// overrun. It could not have: the requests were serialised, so the budget they
// overran was the very same one. It is a single call now because a
// request-per-item fan-out cannot be parallel from the client at all.
// Which sources are worth probing is decided on the SHARED VM, where the
// backoff state lives; only those get sharded.
//
// Asking each shard to decide for itself does not work: a shard has its own
// PLUGIN.state, so it cannot see the memo and a host that keeps failing is
// re-probed on every refresh forever. Measured before this split: fanout left
// the memo at 0 entries and every refresh cost the full 12s.
var plan = await pluginAPI.call("quota","due_sources",{sources:srcs});
// An EMPTY Lua table decodes as {} on the Go side, not [] — and {} is truthy,
// so `|| []` does not replace it. An empty object reaching out.concat()
// throws, and the whole refresh reports a failure. Array.isArray is the check
// that matches what the plugin actually returns.
var due = (plan && Array.isArray(plan.due)) ? plan.due : [];
var out = (plan && Array.isArray(plan.backing_off)) ? plan.backing_off : [];
if (due.length) {
out = out.concat(await pluginAPI.fanout("quota","collect_source",due.map(function(s){
return {sources:[s],name:s.name};
})));
}
// Mirror the host→vendor map the plugin keeps: a source that answered is
// worth probing first next time, and the page has everything it needs to
// record that without another round trip.
var memo = {};
(out||[]).forEach(function(r){
if(r && !r.skipped && !r.error && r.host && r.vendor) memo[r.host] = r.vendor;
});
window.__qaMemo = memo;
// Hand the whole pass back to the SHARED VM so the backoff has something to
// work from next time. Without this the shards' results die with their VMs:
// measured, the memo stayed at 0 entries and every refresh paid full price.
await pluginAPI.call("quota","memo_record",{results:(out||[]).map(function(r){
if(!r) return null;
// A host that is merely BACKING OFF has not just failed; recording it as a
// failure would grow the streak on a probe that never happened. Keyed on the
// flag rather than the wording, so translating the message cannot silently
// turn every skipped host into an ever-growing streak.
if(r.backing_off===true) return null;
// A host probed while an adapter had no configuration was not judged.
// Recording it would earn a backoff that outlives the configuration
// mistake and suppresses the probe that would now succeed.
if(r.unready===true) return null;
if(r.error) return {host:r.host};
if(r.skipped) return {host:r.host};
return {host:r.host, vendor:r.vendor};
}).filter(Boolean)});
}catch(e){
if(btn) btn.disabled=false;
if(sub) sub.textContent="探测失败: "+e.message;
return;
}
var all=out||[];
var real=all.filter(function(r){return !r.skipped;});
// The records, not their names — render() groups skipped sources by the
// backing_off flag, which a bare name cannot carry. This call site was left
// mapping to names while the other was fixed, and the mismatch showed up as
// a footer listing thirteen unnamed sources.
var skipped=all.filter(function(r){return r.skipped;});
// real may already contain errors; collect_all keeps them for visibility.
render(real, skipped);
if(btn) btn.disabled=false;
// Persist both the snapshot and the probe map; the map is what lets the
// next page open skip the upstreams that expose nothing.
try{
await pluginAPI.postState("quota",
{reports:all, updated_at:Date.now(), probe_memo:(window.__qaMemo||{})});
}catch(e){}
}
function mount(){
var b=document.getElementById("qa-refresh");
if(b && !b.__qaWired){ b.__qaWired=true; b.addEventListener("click",refresh); }
}
mount();
showSnapshot();
// Do NOT auto-refresh on load.
//
// Probing is network-bound and every request takes the plugin's write lock, so
// a page open that fires one call per source serialises them: measured 19s to
// first paint and 22-100s to finish, with the whole WebUI held in its loading
// state meanwhile. A balance the operator did not ask for is not worth the
// first paint of the page they opened.
//
// What IS shown automatically is the last snapshot, so an operator who hits
// refresh once sees a card immediately and a fresh number behind it.
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(mount);
window.__qaRefresh=refresh; window.__qaRender=render;
})();
</script>
]==],
},
}
return PLUGIN