mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
端到端验证时撞出两个真缺陷,都是单元测试全绿、隔离实例打真上游才暴露。
一、负 memo 语义混用
负 memo 说的是「这台 HOST 没有余额端点」,且无条件在每次探测前生效。
openDesign 缺 control_key 说的是「这台部署还没配」—— 两件事共用一个条目:
1. 未配 control_key → 探测 → 负 memo
2. 操作员写入 key → 探测被退避拦下,adapter 根本没跑
3. 结果:修复看起来无效,源持续沉默(退避窗口可达数小时)
不能靠「skip 就不记 memo」解决:真实 vela 主机上另外三个 adapter 都返回
404(关于主机的持久结论,正是 memo 存在的理由),verdict 一定是普通 skip。
做法是 memo 条目带上「哪些 vendor 因自身未配置而无法判断」:
- 未就绪的 adapter 直接不跑(跑了只能得到 401 或伪造的 skip)
- 条目带 unready 集合;该 vendor 配置好后条目作废重算
- 仍缺配置时条目依然写入(否则无看板的主机回到每次开页 60 秒超时),
但不退避 —— 退避会拒绝那个本来能成功的探测
二、401 被下一个 adapter 的 404 掩盖
adapter 循环不停在 error,而「最后一个胜」不精确:openDesign 走厂商的整份
主机列表,是最可能看到「key rejected」的那个,但它也可能排在最前(memo 记
得它应答过,下次就优先它)—— 于是它的 401 被下一个 adapter 的
404「no dashboard here」分类成「这台主机没有余额端点」。两种情况的修复动作
完全相反,输出却一样。规则改为:被拒凭据优先,且不被覆盖。
判据:12/12 变异全杀。三条曾经存活或误杀,逐条查清:
- rejected-as-skip 存活是因为测试没有复现生产调用顺序(memo 空 → openDesign
排最后 → 旧规则也能过),测试已改为预置 memo 并让另三个 adapter 走同一个
stub(它们读 base 而非 api_base,否则会全部传输失败)
- no-cred-guard 并非存活,只是杀它的不是我以为的那个测试(门禁后面还有
一道检查),预期已改正
- unready-memo-not-dropped 是真等价变异(known=false 与 nil 只差一次廉价
探活),已从清单移除
(cherry picked from commit 83f1ec0807)
1419 lines
66 KiB
Lua
1419 lines
66 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", "due_sources", "memo_record",
|
||
"resolve_api_base" }
|
||
|
||
-- 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.
|
||
-- hostOf returns the host of a URL — the part memoisation is keyed on, so two
|
||
-- sources pointing at the same host with different paths share one answer.
|
||
--
|
||
-- It used to be written with string.gmatch and a capture group. gmatch yields
|
||
-- WHOLE matches, not captures, and with `^` it never advances, so the iterator
|
||
-- returned nil on the first step and hostOf returned its `or u` fallback —
|
||
-- the FULL URL.
|
||
--
|
||
-- The consequence was invisible and total. Every lookup was HOST_MEMO[nil] and
|
||
-- every write was HOST_MEMO[nil], so:
|
||
-- - the memo table never returned anything, for any host, ever
|
||
-- - the negative memo, the TTL and (later) the backoff were all dead code
|
||
-- - the 19 entries in this gateway's production state are unreachable: their
|
||
-- keys are full URLs, while a lookup asks for nil
|
||
-- Measured before the fix: hostOf(key) matched 0 of 19 production entries.
|
||
local function hostOf(u)
|
||
return (string.match(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
|
||
|
||
-- strang reads a string field, returning nil rather than "". An empty string
|
||
-- and an absent field must stay distinguishable: the UI prints the first as
|
||
-- blank and the second as "未探测", and collapsing them loses that.
|
||
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
|
||
|
||
-- 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
|
||
|
||
-- copySet returns a shallow copy of a set-table, or nil when it is empty.
|
||
--
|
||
-- The copy matters: a memo entry outlives the call that wrote it, and storing
|
||
-- the same table would let a later write mutate a record already handed out.
|
||
-- nil for an empty set keeps "nothing was unready" out of the persisted state,
|
||
-- so the read side can use plain truthiness on the field.
|
||
local function copySet(t)
|
||
local out = nil
|
||
for k, v in pairs(t) do
|
||
out = out or {}
|
||
out[k] = v
|
||
end
|
||
return out
|
||
end
|
||
|
||
-- vendorReady reports whether an adapter has the configuration it needs to answer
|
||
-- at all.
|
||
--
|
||
-- OpenDesign is the first adapter that needs a credential the SOURCE does not
|
||
-- carry (prices.control_key). When it is missing the adapter should not run: with
|
||
-- no credential the only possible outcomes are a 401 (filed as an ERROR, "key
|
||
-- rejected") or a fabricated skip, and neither is a fact about the host. What it
|
||
-- would actually be reporting is that the deployment is not configured yet.
|
||
--
|
||
-- Per-VENDOR rather than one global flag, so a future adapter that needs its own
|
||
-- configuration gets the same treatment by adding a branch here.
|
||
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
|
||
-- The reason travels with the refusal: "adapter not configured" alone says
|
||
-- what is wrong but not what to type.
|
||
return false, "control_key not configured (set prices.control_key)"
|
||
end
|
||
end
|
||
-- Every other adapter carries its credential on the source itself.
|
||
return true, nil
|
||
end
|
||
|
||
-- resolveApiBase turns a configured source base_url into the OpenDesign billing
|
||
-- origin.
|
||
--
|
||
-- Split out as its own function because it is the one piece of this adapter that
|
||
-- is pure logic, and it is load-bearing while being silent when it breaks: the
|
||
-- probe simply goes to the inference host, which serves no billing route, and the
|
||
-- source quietly stays unlisted.
|
||
--
|
||
-- The host is taken as a HOST, not as the tail of a URL. base_url is normally
|
||
-- `https://amr-link.open-design.ai/v1`, and matching a `^...$` pattern against
|
||
-- that whole string never succeeds — the rewrite then silently does not happen.
|
||
-- That was a real defect here, found by asserting behaviour rather than source
|
||
-- text.
|
||
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
|
||
|
||
-- resolve_api_base exposes resolveApiBase for tests, so the derivation can be
|
||
-- asserted directly instead of inferred from which error a failed probe happened
|
||
-- to report. Declared AFTER the local function it calls — a Lua local is nil until
|
||
-- its assignment runs, and this closure would otherwise capture nil. Pure: no
|
||
-- network, no state.
|
||
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
|
||
|
||
-- openDesign probes OpenDesign / Vela (amr-link.open-design.ai).
|
||
--
|
||
-- This vendor needs a credential the source does not carry. Inference runs on
|
||
-- `runtimeKey`, but the billing endpoints on amr-api.open-design.ai only accept
|
||
-- `controlKey` — a second, separate secret handed out when the desktop app signs
|
||
-- in and stored in ~/.amr/config.json under profiles.prod.
|
||
--
|
||
-- Verified against the live service (200 on both, ~1.3s each):
|
||
-- GET https://amr-api.open-design.ai/api/v1/wallet/balance (controlKey)
|
||
-- {balanceUsd, expiresAt, workspaceId, ...}
|
||
-- GET https://amr-api.open-design.ai/api/v1/billing/summary (controlKey)
|
||
-- {creditsPerUsd, membershipTier, subscriptionStatus,
|
||
-- subscriptionCreditsUsd, rechargeCreditsUsd,
|
||
-- todayConsumedUsd, totalConsumedUsd, subscriptionCurrentPeriodEnd, ...}
|
||
--
|
||
-- The two keys are NOT interchangeable: sending runtimeKey to either endpoint
|
||
-- answers 401 {"error":"unauthenticated"}. That is why this adapter reads
|
||
-- PLUGIN.prices instead of reusing the source's api_key.
|
||
--
|
||
-- config location: `prices.control_key` in the plugin's persisted config (the
|
||
-- same file billing uses for its price table — the plugin system persists one
|
||
-- `prices` table per plugin, separate from `state`). Set it through the admin
|
||
-- API:
|
||
-- PUT /api/plugins/quota/state {"prices": {"control_key": "vela_ctrl_..."}}
|
||
-- `prices.api_base` is optional and overrides the amr-link -> amr-api host
|
||
-- derivation.
|
||
--
|
||
-- Three things this adapter deliberately does NOT do:
|
||
--
|
||
-- * It does not invent an exchange rate. creditsPerUsd is the only thing that
|
||
-- turns credits into dollars, and a missing or non-positive value is an
|
||
-- error, not a default. The vendor serves pre-computed *Usd fields too, so
|
||
-- the rate is only needed for the credit breakdown.
|
||
-- * It does not report a period window when the account has no Coding Plan.
|
||
-- `vela billing workspace-snapshot` returns billingState:"free" and NO
|
||
-- codingPlan.windows for such workspaces, and rendering "100% remaining"
|
||
-- from an absent field is the exact lie this plugin was written to avoid.
|
||
-- * It does not treat a zero balance as an exhausted plan. balanceUsd and the
|
||
-- period windows are different things; conflating them was a previous bug.
|
||
local function openDesign(base, key)
|
||
-- The source's own api_key is the RUNTIME key, which these endpoints reject.
|
||
-- The control key comes from the plugin's persisted config.
|
||
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
|
||
|
||
-- amr-link serves inference; amr-api serves billing. Rewriting the host here
|
||
-- rather than asking the operator to configure a second source keeps one
|
||
-- logical provider in the UI.
|
||
--
|
||
-- `api_base` overrides the derivation: the vendor has already moved these
|
||
-- hosts once, and an operator who can see the new one in a browser should not
|
||
-- be blocked on a code change to use it.
|
||
local api = resolveApiBase(base, type(cfg) == "table" and cfg.api_base or nil)
|
||
|
||
-- The gateway's outbound allowlist is the hosts of configured sources. It
|
||
-- therefore contains amr-link (the inference host) but NOT amr-api (the
|
||
-- billing host), and every request below would be refused with "host ... is
|
||
-- not an allowed outbound destination" — a message that reads like a plugin
|
||
-- bug rather than a missing allowlist entry. Add the derived host alongside
|
||
-- the source hosts in core.sourceHosts when this adapter is enabled.
|
||
|
||
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
|
||
-- A 404 here means the host is not amr-link/amr-api at all; skip rather
|
||
-- than error so the memo does not blacklist an unrelated provider.
|
||
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",
|
||
-- tonum would turn a missing balance into 0, i.e. "you are broke", which is
|
||
-- the failure mode this whole plugin exists to avoid. A balance that is
|
||
-- absent stays absent and the guard below turns it into a skip.
|
||
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"),
|
||
}
|
||
|
||
-- Credits breakdown. Converted with the vendor's OWN rate rather than
|
||
-- assuming 10000 — that is this vendor's current value and hardcoding it
|
||
-- would silently produce wrong numbers the day it changes.
|
||
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
|
||
-- No usable rate. Flag it instead of guessing; the *Usd fields below still
|
||
-- give a correct balance, so the card renders with the split missing.
|
||
rep.credits_rate_missing = true
|
||
end
|
||
-- The vendor's own pre-computed dollar amounts win over our conversion.
|
||
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
|
||
|
||
-- Spend, shown next to the balance.
|
||
rep.today_used = getnum(b, "todayConsumedUsd")
|
||
rep.period_used = getnum(b, "totalConsumedUsd")
|
||
rep.total_recharged = getnum(b, "totalRechargedUsd")
|
||
rep.usage_count = getnum(b, "usageCount")
|
||
|
||
-- Wallet expiry: this balance does not last forever. Surfaced because a
|
||
-- balance that silently stops being spendable is worse than a warning.
|
||
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")
|
||
|
||
-- Period windows: deliberately NOT set. codingPlan.windows only exists for a
|
||
-- workspace on a Coding Plan, and this one reports billingState:"free".
|
||
-- Leaving rep.windows nil makes the page say "no period data" rather than
|
||
-- fabricating a 100% that an operator would read as "plenty left".
|
||
--
|
||
-- When a workspace does have a plan the windows arrive as
|
||
-- {durationSeconds, usedCredits, limitCredits, remainingCredits, resetsAt}
|
||
-- and need the same credits->USD conversion; that branch belongs here, gated
|
||
-- on the response actually carrying codingPlan.
|
||
|
||
if rep.balance == nil then
|
||
return nil, "skip", "no balanceUsd in response"
|
||
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 },
|
||
{ vendor = "opendesign", probe = openDesign },
|
||
}
|
||
|
||
-- 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
|
||
|
||
-- Exponential backoff for hosts that answer nothing.
|
||
--
|
||
-- A flat TTL is the wrong shape for a dead upstream. Measured on this gateway:
|
||
-- two hosts had been unreachable for 10 HOURS, and because PROBE_MEMO_TTL is 30
|
||
-- minutes their negative memo was stale on every single page open — so each
|
||
-- refresh re-paid the full cost of three adapters (measured: 24s per source
|
||
-- before the probe budget was fixed, 16s after). A host that is down should not
|
||
-- be re-examined every 30 minutes forever; each consecutive failure doubles the
|
||
-- wait, so a long-dead host costs almost nothing while a host that was briefly
|
||
-- down is retried within the TTL as before.
|
||
--
|
||
-- The cap matters as much as the doubling: without it a host that is down for a
|
||
-- week stops being probed at all, and the page would quietly and permanently
|
||
-- misreport it as having no balance endpoint.
|
||
--
|
||
-- SUCCESS resets the streak. This is only a probe-scheduling decision — a source
|
||
-- that stops answering is still shown, still counted, and still retried.
|
||
local BACKOFF_BASE = 1800 -- first retry after the usual TTL
|
||
local BACKOFF_FACTOR = 2 -- doubles per consecutive miss
|
||
local BACKOFF_MAX = 21600 -- 6 hours; beyond this we always re-probe
|
||
|
||
-- backoffSeconds returns how long to wait before re-probing a host that has
|
||
-- missed `streak` times in a row, and 0 when it is due now.
|
||
--
|
||
-- `at` is when the host last failed. streak is stored inside the memo table
|
||
-- itself so it survives a restart: a process restart must not reset the backoff
|
||
-- and re-pay the cost it was avoiding.
|
||
-- Returns a BOOLEAN and nothing else.
|
||
--
|
||
-- In Lua only nil and false are falsy, so every path here must return one of
|
||
-- those. Two versions of this got it wrong and both were caught by a test that
|
||
-- asserts the type: `return 0` for "no streak" (0 is truthy, so a host with no
|
||
-- streak — which is every memo written before this change — would be skipped
|
||
-- forever, silently), and `return BACKOFF_MAX` on the cap path (21600 is
|
||
-- truthy too, so a long-dead host would never be re-probed, which is the exact
|
||
-- failure the cap exists to prevent).
|
||
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
|
||
|
||
-- 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
|
||
-- Vendors that could not judge the host because their OWN configuration is
|
||
-- missing. Stamped onto the negative memo so it can be retired the moment that
|
||
-- configuration arrives.
|
||
local unready = {}
|
||
-- A REJECTED credential outranks every other error, and the reason is the
|
||
-- only thing this variable is for.
|
||
--
|
||
-- Why the rule is not simply "last wins": the four adapters answer different
|
||
-- questions, and a general-purpose one (openDesign, which walks the vendor's
|
||
-- whole host list) can report "key rejected" while an adapter that merely could
|
||
-- not reach ITS host reports a transport error. The two need opposite
|
||
-- responses — re-issue the credential vs. investigate the network — and with a
|
||
-- plain "last wins" the credential problem is reported as a network one.
|
||
--
|
||
-- Conversely, "first error wins" is just as arbitrary in the other direction:
|
||
-- an adapter whose host is unreachable from this deployment fails before the
|
||
-- one that actually knows what is wrong.
|
||
local errWhy, errRejected = nil, false
|
||
-- 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
|
||
-- A memo written while an adapter could not judge the host is PROVISIONAL.
|
||
-- It is discarded as soon as that adapter becomes configured, and it never
|
||
-- earns a backoff while it is still waiting.
|
||
--
|
||
-- Why it must not back off: the memo is applied unconditionally, before any
|
||
-- adapter runs. Honouring a provisional entry refuses the very probe that is
|
||
-- now able to succeed, so the operator supplies the missing credential and
|
||
-- sees nothing change. Measured end to end before this: unset key → memo →
|
||
-- key set → probe skipped without a single request.
|
||
--
|
||
-- The comparison is against the vendors RECORDED in the entry, not against
|
||
-- all adapters. "Is any adapter configured" is true almost always — three of
|
||
-- the four carry their credential on the source — and would retire every
|
||
-- provisional entry immediately, including the ones still waiting.
|
||
local provisional = type(known.unready) == "table"
|
||
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
|
||
-- The missing configuration is here: derive the verdict again.
|
||
known = nil
|
||
else
|
||
-- Still waiting for it. Do NOT back off: the memo is applied before any
|
||
-- adapter runs, so honouring it would refuse the very probe that will
|
||
-- succeed once the credential arrives.
|
||
known = false
|
||
end
|
||
else
|
||
-- A streak is a host that has failed repeatedly. Skip it outright until its
|
||
-- backoff expires, instead of re-paying three adapter timeouts every TTL.
|
||
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 -- still "nothing answered, try cheapest first"
|
||
end
|
||
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
|
||
-- An adapter that cannot answer for want of its OWN configuration is not
|
||
-- probed at all. Running it would produce a 401 (reported as ERROR "key
|
||
-- rejected") or a fabricated skip, and would send the operator to re-issue a
|
||
-- credential they have not configured yet.
|
||
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
|
||
-- Last error wins, except that a rejected credential displaces anything
|
||
-- already recorded and is never displaced in turn.
|
||
if not errRejected and (errWhy == nil or rejected) then
|
||
errWhy, errRejected = e, rejected
|
||
end
|
||
end
|
||
end
|
||
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.
|
||
-- Record the negative result, and GROW THE STREAK.
|
||
--
|
||
-- The guard used to be `not HOST_MEMO[host]`, which is true only the FIRST time
|
||
-- a host fails: on every later failure the memo already held a table, the
|
||
-- guard was false, and nothing was written. The streak therefore stayed pinned
|
||
-- at 1 forever and the backoff never grew. Measured while writing this: the
|
||
-- memo read back `{at:... streak:1}` after four consecutive failures.
|
||
--
|
||
-- A success overwrites the memo with a vendor string, so a host that recovers
|
||
-- resets its streak automatically.
|
||
--
|
||
-- The entry records WHICH vendors could not judge the host. Such a verdict is
|
||
-- provisional (see the read side): the memo is applied before every later
|
||
-- probe, so keeping it unqualified suppresses the source even after the
|
||
-- credential is supplied.
|
||
--
|
||
-- It is still WRITTEN rather than omitted, because dropping it would put a
|
||
-- dashboard-less source back to 60s of adapter timeouts on every page open —
|
||
-- the cost the memo exists to remove. A memo cannot say "three of four
|
||
-- answered, the fourth is waiting for a key", so it says the next best thing.
|
||
--
|
||
-- Its streak also does not carry over from a previous provisional entry: that
|
||
-- count would be spent on a judgement nobody made.
|
||
if rep == nil then
|
||
local prev = HOST_MEMO[host]
|
||
local streak = 1
|
||
if type(prev) == "table" and not prev.unready 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
|
||
-- An ERROR is reported when either:
|
||
-- * the LAST adapter to judge the host reported one, or
|
||
-- * ANY adapter reported a REJECTED credential.
|
||
--
|
||
-- The second clause is not a refinement, it is the whole point. openDesign
|
||
-- walks the vendor's host list and is therefore the adapter most likely to
|
||
-- see "key rejected", but it also runs FIRST once it has been memoised as the
|
||
-- answering vendor — so a last-wins rule classifies its 401 by whatever the
|
||
-- next adapter says, and the next adapter (commandcode, pointed at the
|
||
-- source's own host) answers 404 "no dashboard here". Observed end to end:
|
||
-- a deliberately wrong control key was reported as "this host has no balance
|
||
-- endpoint", sending the operator to investigate the vendor instead of
|
||
-- re-issuing the credential. A rejection has one unambiguous remedy, so it
|
||
-- outranks position.
|
||
if kind == "error" or errRejected then
|
||
return { name = src.name, vendor = "", error = tostring(errWhy or why),
|
||
skipped = false, host = hostOf(base) }
|
||
end
|
||
-- The skip REASON travels. Discarding it here is why
|
||
-- TestQuotaOpenDesignSkipsWithoutControlKey could not tell a configured
|
||
-- adapter that refused from a source that never reached it: both arrived as
|
||
-- {skipped=true}, so a mutation that ignored the missing credential was
|
||
-- invisible at this boundary. "Not configured" is actionable (set the key);
|
||
-- "no balance endpoint" is not.
|
||
local out = { name = src.name, skipped = true, host = hostOf(base) }
|
||
if why and why ~= "" then out.reason = tostring(why) end
|
||
-- `unready` travels with the record because the page fans out on shard VMs
|
||
-- and reports back through memo_record, where a row with no vendor and no
|
||
-- error is otherwise indistinguishable from a real failure. Without it the
|
||
-- sharded path — the one the page actually uses — would still earn the source
|
||
-- a backoff it should never have had.
|
||
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
|
||
|
||
-- 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.
|
||
-- due_sources answers, on the SHARED VM, which sources are worth probing right
|
||
-- now — and it makes NO network requests.
|
||
--
|
||
-- This exists because of a conflict between two facts:
|
||
--
|
||
-- - Probing is network-bound, and Plugins.Call holds the plugin mutex for the
|
||
-- whole Lua function, so twenty probes through /call/ serialise. Measured:
|
||
-- 29s on a 20-source gateway. The fix is ShardCall, which runs each call on
|
||
-- its own VM — and a shard VM has its own PLUGIN.state.
|
||
-- - The backoff lives in PLUGIN.state (HOST_MEMO). A shard therefore cannot
|
||
-- see it, cannot record it, and a host that fails keeps being probed on every
|
||
-- page open. Measured: fanout left the memo at 0 entries, collect_all reached
|
||
-- 17.
|
||
--
|
||
-- So the split is: the DECISION runs where the state is (one shared-VM call,
|
||
-- microseconds), and only the hosts that pass it get sharded.
|
||
--
|
||
-- Cheap on purpose. It reads HOST_MEMO and nothing else; if the state has not
|
||
-- been restored yet, every host is due, which is the correct answer for a first
|
||
-- call.
|
||
-- memo_record writes the outcome of a sharded pass into the SHARED VM's memo.
|
||
--
|
||
-- A shard runs on its own VM and therefore cannot record anything: measured, a
|
||
-- page of fanouts left the memo at 0 entries, so the backoff never accumulated
|
||
-- and every refresh re-probed the dead hosts. This is the other half of
|
||
-- due_sources — the decision is taken here, the outcome is written back here, and
|
||
-- only the network wait happens on the shards.
|
||
--
|
||
-- It records FAILURES as well as successes. Recording only successes is what
|
||
-- left every source with no balance endpoint re-probing three adapters forever.
|
||
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
|
||
-- A source that answered resets its streak: the memo becomes a vendor
|
||
-- string, which every reader already treats as a positive memo.
|
||
HOST_MEMO[host] = r.vendor
|
||
else
|
||
local prev = HOST_MEMO[host]
|
||
local streak = 1
|
||
-- A provisional entry is replaced, not aged: its count describes a
|
||
-- judgement nobody made. See the write side in collectSource.
|
||
if type(prev) == "table" and not prev.unready then
|
||
streak = (prev.streak or 1) + 1
|
||
end
|
||
-- The page can only send a boolean (it does not know which adapter was
|
||
-- unready), so the set is re-derived here on the shared VM. Copying it
|
||
-- matters: this table is stored, and a later write must not mutate it.
|
||
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
|
||
-- skipped matters: the page filters on it. Without the flag a backed-off
|
||
-- host reaches the card renderer, which has no field to match and falls
|
||
-- through to its "has data" default — so thirteen dead upstreams were
|
||
-- displayed as if they had answered.
|
||
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
|
||
-- 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 = [==[
|
||
<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 |