diff --git a/internal/lua/plugins/quota.lua b/internal/lua/plugins/quota.lua new file mode 100644 index 0000000..532edba --- /dev/null +++ b/internal/lua/plugins/quota.lua @@ -0,0 +1,761 @@ +-- 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 = { + page = { + page_id = "quota", + title = "Quota", + order = 45, + mount = [==[ +
+
+
+
套餐余量
+
点击刷新
+
+
+ +
+
尚未拉取
+
+ + +]==], + }, +} + +return PLUGIN \ No newline at end of file