-- 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

-- provisionalEntry reports whether a negative memo was written while some adapter
-- could not judge the host. Such an entry is retired the moment that adapter
-- becomes configured, and never backs off while it is still waiting.
--
-- An EMPTY `unready` is not provisional, and the emptiness has to be tested rather
-- than the presence. Two ways an empty set shows up: the write path drops empty
-- tables (so a host every adapter judged has no field at all), but a full JSON
-- round trip — restore, or one page open — turns an empty table back into a
-- truthy `{}`. Reading presence as the signal therefore makes exactly the hosts
-- that most need backing off the ones that never get it.
local function provisionalEntry(entry)
  if type(entry) ~= "table" then return false end
  if type(entry.unready) ~= "table" then return false end
  return next(entry.unready) ~= nil
end

-- 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 = provisionalEntry(known)
    local stale = false
    if provisional then
      for vendor in pairs(known.unready) do
        if vendorReady(vendor) then stale = true end
      end
    end
    if provisional then
      if stale then
        -- 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, and the backoff is keyed on
      -- the RECORDED STREAK, not on this entry existing.
      --
      -- Keying it on existence reads like a tautology that can never fire, but it
      -- fires constantly, because of WHEN the write side runs: the entry is read at
      -- the top of this function and only written at the very bottom, so mid-call
      -- the table still holds the two adapters that already answered on THIS call
      -- — typically the two with the shortest timeouts. LIVE DEFECT: the plugin
      -- then reported "连续探测失败 1 次" for a streak that was never counted and
      -- refused every later probe, so an operator who supplied the missing key saw
      -- nothing change, indefinitely.
		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.
  --
  -- `unready` is copied, and copySet returns nil for an EMPTY set — which is what
  -- makes "every adapter judged the host" arrive as an absent field rather than as
  -- an empty table. The read side tests for a NON-EMPTY set rather than for the
  -- field's presence, so the two spellings of "nothing was unready" agree.
  if rep == nil then
    local prev = HOST_MEMO[host]
    local streak = 1
    local prevUnready = provisionalEntry(prev)
    if type(prev) == "table" and not prevUnready then
      streak = (prev.streak or 1) + 1
    end
    HOST_MEMO[host] = { at = os.time(), streak = streak, unready = copySet(unready) }
  end
  if rep ~= nil then
    HOST_MEMO_AT[host] = os.time()
  end
  if rep == nil then
    -- 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",
    -- ★ 侧边栏图标必须是 **inline SVG**(空串=空白,emoji=尺寸/主题不一致)。
    --   图案:三格电池 —— “套餐余量”的直接语义。
    icon = [==[<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="2.5" y="7" width="16" height="10" rx="2.4"/><path d="M21.5 10.5v3"/><path d="M6 10.5v3M10 10.5v3M14 10.5v3"/></svg>]==],
    order = 45,
    mount = [==[
<div id="qa-root" class="qa-root">
  <div class="qa-head">
    <div>
      <div class="qa-title">套餐余量</div>
      <div class="qa-sub" id="qa-sub">点击刷新</div>
    </div>
    <div class="grow"></div>
    <button class="small" id="qa-refresh">刷新</button>
  </div>
  <div id="qa-list"><div class="qa-empty">尚未拉取</div></div>
</div>
<style>
#qa-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
.qa-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
.qa-title{font-size:15px;font-weight:600}
.qa-sub{font-size:12px;opacity:.66;margin-top:2px}
.qa-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 10px;min-width:0}
.qa-acc.err{border-color:rgba(220,80,80,.5)}
.qa-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
.qa-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:46%}
.qa-host{font-size:11.5px;opacity:.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.qa-vendor{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.8;white-space:nowrap}
.qa-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
.qa-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
.qa-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
.qa-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
.qa-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 8px;flex-wrap:wrap}
.qa-bal-n{font-size:19px;font-weight:600}
.qa-bal-l{font-size:12px;opacity:.66}
.qa-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:9px 18px}
.qa-w{min-width:0}
.qa-w-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
.qa-w-n{font-variant-numeric:tabular-nums;font-weight:600}
.qa-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
.qa-fill{height:100%;background:#3b82f6;border-radius:3px}
.qa-fill.warn{background:#d97706}
.qa-fill.over{background:#dc2626}
.qa-w-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
.qa-meta{display:flex;gap:13px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:9px}
.qa-fail{font-size:11.5px;color:#b91c1c;margin-top:7px;word-break:break-word}
.qa-warnl{font-size:11px;color:#b45309;margin-top:3px}
.qa-empty{opacity:.6;font-size:13px;padding:8px 0}
.qa-skip{font-size:11.5px;opacity:.5;margin-top:8px;line-height:1.6}
</style>
<script>
(function(){
  var MONTHLY_CAP_NOTE = "月度上限按套餐表推导";
  function escH(s){return String(s==null?"":s).replace(/[&<>"]/g,function(c){
    return c==="&"?"&amp;":c==="<"?"&lt;":c===">"?"&gt;":"&quot;";});}
  // 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