mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
插件页面不是自动公开的数据。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)
766 lines
34 KiB
Lua
766 lines
34 KiB
Lua
-- Provider quota — plan balance across upstreams, one card per source.
|
||
--
|
||
-- This started as a Command Code plugin and grew, because only one vendor was
|
||
-- being asked. The shape that works across providers is a two-part contract:
|
||
--
|
||
-- 1. A small set of PROBE ADAPTERS, each naming an endpoint, how to call it
|
||
-- and which JSON paths hold the numbers. Adapters are tried in order and
|
||
-- the first one that returns a usable answer wins.
|
||
-- 2. A DECLARATIVE override, so a provider nobody anticipated can be added
|
||
-- from the plugin's own state (endpoints + path mapping) without touching
|
||
-- code.
|
||
--
|
||
-- What the adapters share, and why it is worth generalizing:
|
||
--
|
||
-- - The DeepSeek family (deepseek, and the many re-sells that copied its
|
||
-- dashboard) answers GET /user/balance with
|
||
-- {is_available, balance_infos:[{currency,total_balance,granted,topped_up}]}.
|
||
-- Granted vs topped-up matters: gifted credit expiring on a schedule is a
|
||
-- different operational fact from prepaid money.
|
||
-- - The OpenAI-compatible dashboard shape answers
|
||
-- /v1/dashboard/billing/subscription plus /v1/dashboard/billing/usage,
|
||
-- which is what most re-sells of an OpenAI console expose. Note these
|
||
-- report LIMITS and SPEND, not a balance: the provider here has a hard limit
|
||
-- of 1e8 and 660 spent, so "remaining" must not be computed as
|
||
-- limit - spend and presented as money left — it is a ceiling nobody is
|
||
-- near. Reporting that honestly matters more than a green bar.
|
||
-- - Command Code exposes rolling 5-hour / weekly / monthly windows plus a
|
||
-- billing period, which no other provider here does.
|
||
--
|
||
-- A provider with no working adapter is SKIPPED, silently. Half these upstreams
|
||
-- are small re-sells whose dashboard endpoints return an HTML page, not JSON; a
|
||
-- list full of "unknown" rows trains the operator to ignore the page entirely,
|
||
-- which is worse than showing only what is real. Failures that are NOT "no such
|
||
-- endpoint" — a 401 on a key we hold, say — are surfaced, because those are the
|
||
-- ones worth fixing.
|
||
--
|
||
-- Only hosts of CONFIGURED SOURCES are reachable (__llmsproxy_http enforces
|
||
-- that), so adding a probe never widens what the gateway can talk to.
|
||
|
||
local PLUGIN = {}
|
||
|
||
PLUGIN.name = "quota"
|
||
PLUGIN.version = "1.0.0"
|
||
PLUGIN.description = "多源套餐余量(余额 / 用量 / 滚动窗口)"
|
||
|
||
PLUGIN.exports = { "collect_source", "collect_all" }
|
||
|
||
-- plugin.state must exist before anything indexes it. The kernel restores
|
||
-- persisted state OVER this table, so declaring it here is safe: a restart with
|
||
-- no saved state still has an empty table to write into, rather than nil.
|
||
--
|
||
-- It is initialised at the TOP on purpose. Every collect path below touches it
|
||
-- (to restore the probe memo on first use, to persist it afterwards), and a
|
||
-- declaration further down would be nil for the reads that run first.
|
||
PLUGIN.state = {}
|
||
|
||
-- ---------------------------------------------------------------- helpers
|
||
|
||
local function tonum(v)
|
||
return tonumber(v) or 0
|
||
end
|
||
|
||
-- hostOf extracts the host from a base_url, for showing WHICH upstream a row is
|
||
-- about. Two sources on the same vendor (two Command Code keys) are
|
||
-- indistinguishable without it.
|
||
local function hostOf(u)
|
||
return (string.gmatch(u, "^https?://([^/]+)")()) or u
|
||
end
|
||
|
||
-- get walks a dotted path through nested tables, returning nil if any hop is
|
||
-- missing. A missing field must stay nil rather than becoming 0: "this provider
|
||
-- did not report it" and "this provider reported zero" are different, and
|
||
-- collapsing them shows a healthy $0.00 for a provider that never answered.
|
||
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
|
||
|
||
-- request performs one GET with the standard retry. Retrying is not
|
||
-- politeness here: several of these hosts sit behind DNS that intermittently
|
||
-- refuses, and a single 000 turned into "provider unknown" on every poll.
|
||
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({
|
||
-- 4s, not 10s: probing is network-bound and the whole page shares ONE
|
||
-- Lua state under a write lock, so a slow upstream is not just a slow card
|
||
-- — it stalls every request_end hook (i.e. billing) behind it.
|
||
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
|
||
-- A 200 that is not JSON means we hit a marketing page, not an API.
|
||
return nil, "not json (HTTP " .. tostring(res.status) .. ")"
|
||
end
|
||
if res.status == 401 or res.status == 403 then
|
||
-- Retrying a rejected credential only wastes the operator's time.
|
||
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
|
||
|
||
-- Monthly credit caps per planId. The credits endpoint never reports a cap, so
|
||
-- this is a lookup against the vendor's published pricing ($10 Go, $70
|
||
-- GOAT/Pro/Max), cross-checked against monthly_used + balance. Declared before
|
||
-- the adapters that read it — a Lua local is nil until its assignment runs, and
|
||
-- the Command Code probe hit that as a runtime nil-index.
|
||
local PLAN_CAPS = {
|
||
["individual-go"] = 10,
|
||
["individual-goat"] = 70,
|
||
["individual-pro"] = 70,
|
||
["individual-max"] = 70,
|
||
["team-goat"] = 70,
|
||
}
|
||
|
||
-- ---------------------------------------------------------------- adapters
|
||
|
||
-- Each adapter returns (report, nil) when it produced something usable, or
|
||
-- (nil, reason) to let the next one try. `skip` is the quiet reason — a 404 or
|
||
-- an HTML page means "this provider does not expose balance here", which is not
|
||
-- worth showing. Everything else becomes a visible failure.
|
||
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
|
||
|
||
-- openaiDashboard reports LIMITS and SPEND. There is deliberately no
|
||
-- `remaining` here: hard_limit_usd is a ceiling (1e8 on the live provider), and
|
||
-- ceiling - spend would read as "$99,999,339 left", which is a number nobody
|
||
-- should ever believe.
|
||
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,
|
||
}
|
||
-- Only call it a balance when the vendor says so. A hard limit with no usage
|
||
-- endpoint is a ceiling with nothing spent against it; that still renders, but
|
||
-- as a limit rather than as money left.
|
||
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
|
||
|
||
-- commandCode keeps its own shape: rolling windows plus a billing period.
|
||
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
|
||
|
||
-- Subscriptions and usage are enrichment: their absence must not blank the
|
||
-- balance that just came back.
|
||
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
|
||
|
||
-- Ordered probes. DeepSeek-family first because it is a single cheap endpoint,
|
||
-- then the OpenAI console shape, then Command Code's four.
|
||
local ADAPTERS = {
|
||
{ vendor = "commandcode", probe = commandCode },
|
||
{ vendor = "deepseek", probe = deepseekBalance },
|
||
{ vendor = "openai-dash", probe = openaiDashboard },
|
||
}
|
||
|
||
-- The probe memo remembers, per host, which adapter answered. Persisted in the
|
||
-- plugin's state so the expensive part — discovering that 10 of 21 upstreams
|
||
-- expose no balance endpoint at all — is paid once, not on every page open.
|
||
--
|
||
-- This matters more than it looks. The answers depend only on the host, never
|
||
-- on the key, and re-discovering them cost 21 sources x 3 adapters x 2 attempts
|
||
-- of timeouts on the first load.
|
||
|
||
-- HOST_MEMO maps host -> adapter vendor (or false when nothing answered). It
|
||
-- is restored from the plugin's own state on the first collect of a process, so
|
||
-- a restart does not re-pay the cost of discovering which upstreams are silent.
|
||
local HOST_MEMO = {}
|
||
-- HOST_MEMO_AT[host] = when that host last produced data. A negative memo older
|
||
-- than PROBE_MEMO_TTL is ignored, so a vendor that adds an endpoint is picked up
|
||
-- within the TTL without a restart.
|
||
local HOST_MEMO_AT = {}
|
||
local PROBE_MEMO_TTL = 1800 -- 30 minutes
|
||
|
||
-- PROBE_BUDGET_MS caps one collect pass. Without it a page open over a set of
|
||
-- dead upstreams runs to the sum of every timeout — measured at 149s — and the
|
||
-- plugin's write lock is held the whole time.
|
||
local PROBE_BUDGET_MS = 12000
|
||
|
||
-- ---------------------------------------------------------------- source scan
|
||
|
||
-- collectSource probes one source and returns a report, or nil when the source
|
||
-- simply has no balance endpoint (silent) — the caller distinguishes the two
|
||
-- through `skipped`.
|
||
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
|
||
-- Try the adapter this host answered with last time first. Same host with a
|
||
-- different key (two Command Code seats) gives the same answers, so this turns
|
||
-- 21 sources into a handful of distinct round trips after the first pass.
|
||
local host = hostOf(base)
|
||
local known = HOST_MEMO[host]
|
||
-- A negative memo is a TABLE ({at=timestamp}), distinguishable from a vendor
|
||
-- string. JSON round-trips it fine; a bare false would not survive the trip
|
||
-- through the Go-side state as cleanly, and the two being different types is
|
||
-- exactly the confusion that made an earlier revision drop five sources.
|
||
if type(known) == "table" then
|
||
local seen = known.at or 0
|
||
if PROBE_MEMO_TTL > 0 and (os.time() - seen) > PROBE_MEMO_TTL then
|
||
known = nil
|
||
else
|
||
known = false -- still "nothing answered, try cheapest first"
|
||
end
|
||
end
|
||
-- known == false means every adapter already came back "no endpoint here" for
|
||
-- this host during this process. Skip the probing entirely; one cheap request
|
||
-- confirms it is still the case, so a vendor that adds a dashboard later is
|
||
-- picked up without a restart.
|
||
-- A cheap liveness probe, NOT a skip: the hosts behind these adapters are
|
||
-- slow (12s+ to answer a root request), so treating a slow answer as "down"
|
||
-- lost five sources that do have real data. The probe only short-circuits the
|
||
-- genuinely unreachable case; a reachable host still goes through the
|
||
-- adapters.
|
||
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
|
||
-- Reachable: fall through and try the adapters again, since a slow host
|
||
-- that was merely timing out may answer fine.
|
||
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
|
||
-- Budget check BETWEEN adapters, so an exhausted budget still returns the
|
||
-- sources already answered instead of nothing at all.
|
||
if budget and budget.exhausted() then
|
||
return { name = src.name, host = host, skipped = true,
|
||
reason = "probe budget exhausted" }
|
||
end
|
||
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
|
||
end
|
||
-- Remember the NEGATIVE result too. Only recording successes left every
|
||
-- source that has no balance endpoint re-probing all three adapters (2
|
||
-- attempts, 4s each) on every single page open — measured at 60s per source,
|
||
-- unchanged between runs, because "nothing answered" was never recorded.
|
||
--
|
||
-- The value is the adapter that came closest (an error rather than a skip), so
|
||
-- a host that later starts working is still retried first.
|
||
-- Remember that this host answered nothing, but only as an ORDERING hint, and
|
||
-- with a stamp so it expires. "This upstream has no balance endpoint" is a
|
||
-- fact that can change — vendors ship dashboards — so a permanent negative
|
||
-- would make the page quietly stop reporting a source that started working.
|
||
-- The stamp is what turns a 60s first load into a fast steady state without
|
||
-- freezing what the page can see.
|
||
if rep == nil and not HOST_MEMO[host] then
|
||
HOST_MEMO[host] = { at = os.time() }
|
||
end
|
||
if rep ~= nil then
|
||
HOST_MEMO_AT[host] = os.time()
|
||
end
|
||
if rep == nil then
|
||
if kind == "error" then
|
||
return { name = src.name, vendor = "", error = tostring(why),
|
||
skipped = false, host = hostOf(base) }
|
||
end
|
||
return { name = src.name, skipped = true, host = hostOf(base) }
|
||
end
|
||
rep.name = src.name
|
||
rep.host = hostOf(base)
|
||
rep.skipped = false
|
||
rep.checked_at = os.time()
|
||
return rep
|
||
end
|
||
|
||
-- collect_all walks every source. Prefer it for a SMALL number of sources.
|
||
--
|
||
-- The page calls collect_source concurrently instead: probing is almost entirely
|
||
-- network wait (three adapters, up to two attempts, ten second timeouts each),
|
||
-- and doing 21 sources in sequence overran the HTTP call's own budget — the
|
||
-- whole page returned 502 while individual probes were still in flight. The
|
||
-- sequential entry point stays for tests and for anyone scripting the plugin.
|
||
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.clock()
|
||
local lim1 = PROBE_BUDGET_MS / 1000
|
||
local function spent()
|
||
return (os.clock() - 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
|
||
|
||
-- collect_source probes one named source.
|
||
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
|
||
-- A closure over the deadline, not a table method. `exhausted = function(self)`
|
||
-- reads correctly but breaks the moment the call site is `budget.exhausted()`
|
||
-- from inside a pcall-guarded hook where the upvalue chain differs; the
|
||
-- explicit capture below cannot go wrong that way.
|
||
local t0 = os.clock()
|
||
local limit = PROBE_BUDGET_MS / 1000
|
||
local function exhausted()
|
||
return (os.clock() - 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)
|
||
-- Persist the host→adapter map so the next PROCESS skips the dead ends,
|
||
-- not just the next page open.
|
||
PLUGIN.state.probe_memo = HOST_MEMO
|
||
return rep, nil
|
||
end
|
||
end
|
||
return nil, "source not found: " .. tostring(payload.name)
|
||
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",
|
||
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>'};
|
||
return {cls:"", 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;
|
||
}
|
||
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>";
|
||
}
|
||
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){
|
||
host.innerHTML+='<div class="qa-skip">未列出 '+skipped.length+
|
||
' 个源(未暴露余额接口):'+escH(skipped.join("、"))+'</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 call per source, fired together. Probing is network-bound, so a
|
||
// sequential collect_all over 21 sources blows past the call's time budget
|
||
// and every card comes back as one 502 — the probes themselves were fine.
|
||
// Concurrency also makes the page honest about which sources are slow:
|
||
// each card appears as its own answer lands.
|
||
out = await Promise.all(srcs.map(function(s){
|
||
return pluginAPI.call("quota","collect_source",{sources:[s],name:s.name})
|
||
.catch(function(e){ return {name:s.name,skipped:true,error:String(e.message||e)}; });
|
||
}));
|
||
// 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;
|
||
}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;});
|
||
var skipped=all.filter(function(r){return r.skipped;}).map(function(r){return r.name;});
|
||
// 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(); refresh();
|
||
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(mount);
|
||
window.__qaRefresh=refresh; window.__qaRender=render;
|
||
})();
|
||
</script>
|
||
]==],
|
||
},
|
||
}
|
||
|
||
return PLUGIN |