-- 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 -- os.time, NOT os.clock. os.clock measures CPU time, and a probe spends -- essentially none: measured on this gateway, 3s blocked on an HTTP request -- advanced os.clock by 0.001s. A budget written in os.clock therefore never -- fires, which is why a dead upstream ran the full -- 4s timeout x 2 retries x 3 adapters = 24s against a "12 second" budget. 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 -- 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.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) -- 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 = [==[
套餐余量
点击刷新
尚未拉取
]==], }, } return PLUGIN