Files
ModelRouter/internal/lua/plugins/billing.lua
JianFeeeee 400b6c35a5 fix(billing-ui): 规则表列宽锁定,消除表头/按钮重叠
全部靠截图 + 几何测量发现,DOM 断言当时全绿:

- table-layout:fixed 把 954px 均分成 9×106px,写在 <td> 上的每列宽度全部失效 →
  相邻表头叠在一起,源 URL 输入框被压成 "https:"。
- 把宽度改到 <th> 并去掉 fixed → 浏览器改按内容算,<th> 宽度又被忽略,币种列
  仍塌到 48px(输入框只剩 24px,装不下 "USD")。
- 恢复 fixed + 宽度移到 <colgroup>:fixed 下只有首行宽度生效,而「按模型价格」
  格宽度随哪条规则最宽而变(实测 352px vs 206px),整行布局取决于恰好哪条规则
  最宽。colgroup 一次性锁死九列,所有行一致。
- 表头双语长文案(峰段星期 UTC 1=周一)超出列宽 → nowrap + ellipsis,完整文案
  留在 title。
- 删除列 58px 装不下「删除」按钮(折成两行、按钮高过整行)→ 74px,从峰段倍数
  列匀出。
- 价目行 80+96+96+38≈330px 挤在 262px 格子里,× 按钮越界 78px 压在隔壁「删除」
  按钮上 → 输入框改 flex:1 1 …/min-width:0 可收缩,× 保持 flex:0 0 auto。
- 顺带修上轮引入的回归:给 modelBlock 的 flex 换行后模型名不再被截断。

判据 TestRuleTableLocksColumnWidthsInAColgroup + 3 个变异(去掉 min-width:0 /
改列宽数组 / 去掉 table-layout:fixed)全部被捕获。第三个变异第一版漏放——判据用
strings.Contains(js, "table-layout:fixed") 匹配到了上方三行的解释性注释,改成匹配
style='…' 字面量后才真正失败。
2026-10-02 15:29:29 +08:00

1535 lines
68 KiB
Lua
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- billing.lua — usage accounting plugin for ModelRouter.
--
-- Computes what each request cost, from three configurable dimensions:
--
-- source a flat per-request price for an upstream source
-- model a per-token price for a model id (prompt / completion separately)
-- key an override price for one gateway key
--
-- It then keeps running totals for the whole gateway, per source, per model
-- and per key, and publishes them in `plugin.state` so the kernel can serve
-- them at GET /api/plugins/billing/state — which is what its own dashboard
-- component reads.
--
-- ACCOUNTING BOUNDARY (important, and deliberate):
-- this plugin REPORTS; it does not ENFORCE. The gateway's own quota accounting
-- (internal/gateway/stats.go, enforced at request admission) stays
-- authoritative for limits. Two independent accounting paths that disagree are
-- worse than one that is slightly less featureful, so nothing here feeds back
-- into routing or quota decisions.
--
-- PRICE CONFIGURATION
-- Prices are supplied as a Lua table assigned to `billing.prices` before the
-- plugin is loaded, OR at runtime through PUT /api/plugins/billing/state. The
-- shape is:
--
-- billing.prices = {
-- currency = "USD", -- display only, no conversion happens
-- default = { prompt = 0, completion = 0, per_request = 0 },
-- sources = {
-- ["localzen"] = { per_request = 0.0 },
-- ["trae"] = { per_request = 0.01 },
-- },
-- models = {
-- ["gpt-5.4"] = { prompt = 1.25e-6, completion = 1e-5 }, -- USD per TOKEN
-- ["kimi-k3"] = { prompt = 6e-7, completion = 2.5e-6 },
-- ["kolors"] = { per_request = 0.04 }, -- image: flat
-- },
-- keys = {
-- -- by gateway key (the same value the audit log masks to ***xxxxxx)
-- ["***a1b2c3"] = { prompt = 1.1e-6, completion = 9e-6 },
-- },
-- }
--
-- Precedence for a token price: keys > models > default. A flat per_request
-- price, when present at any level, is ADDED on top of the token cost, so an
-- image model can carry both (e.g. tokens billed plus a fixed fee).
--
-- Numbers are USD per single token, which is how providers publish prices. That
-- makes a typical entry look like 1.25e-6; the plugin multiplies by the token
-- count, so no unit conversion happens anywhere.
local plugin = {
name = "billing",
version = "1.0.0",
description = "Per-source / per-model / per-key cost accounting with a dashboard",
author = "ModelRouter",
}
-- ---------- prices ----------
-- plugin.prices can be pre-seeded by embedding this file (an operator edits the
-- table below) or replaced at runtime through the state API. It is a SEPARATE
-- field from plugin.state on purpose: PUT /api/plugins/billing/state replaces
-- `state` wholesale, and prices must not live there or a price update would
-- wipe the accumulated totals. See docs/plugins.md.
local DEFAULT_PRICES = {
currency = "USD",
default = { prompt = 0, completion = 0, per_request = 0 },
sources = {},
models = {},
keys = {},
}
plugin.prices = DEFAULT_PRICES
-- Default prompt-cache discount. 0.1 = a cache read costs a tenth of a fresh
-- token, which is what DeepSeek/Qwen/Kimi and most others charge. It can be
-- overridden per price entry (prices.models.<m>.cache_discount) or globally by
-- setting plugin.cache_discount; 1 restores flat prompt pricing.
plugin.cache_discount = 0.1
-- ---------- accumulated totals ----------
-- state is what the kernel serves at GET /api/plugins/billing/state. It holds
-- ACCUMULATED TOTALS ONLY — prices live in plugin.prices (see above), so
-- replacing state never destroys a price table and updating prices never
-- destroys history.
--
-- Structure:
-- total { cost, requests, prompt_tokens, completion_tokens }
-- by_source { <name> = { cost, requests, ...tokens } }
-- by_model { <model> = { cost, ... } }
-- by_key { <masked key id> = { cost, ... } }
-- by_day { "YYYY-MM-DD" = { cost, ... } }
-- top_sources [ {name, cost, requests}, ... ] sorted, capped
-- top_models [ ... ]
-- top_keys [ ... ]
--
-- Sorted top-N lists are maintained incrementally rather than re-sorted on
-- every request: this hook runs once per request on the hot path, so it does
-- map updates only. The sort happens when state is READ.
-- CACHE ACCOUNTING (added after production showed the gap):
-- the gateway extracts prompt_cache_hit_tokens from upstream usage and puts it
-- in the request_end payload, and costFor() already used it to price the cache
-- leg — but no bucket recorded it. So a gateway where 99.88% of prompt tokens
-- were cache reads showed a prompt_tokens number with no indication of that,
-- and there was no way to see cache hit rate per source/model/key at all.
--
-- cache_hit_tokens hits, as reported by upstream
-- cache_fresh_tokens prompt tokens that were NOT cache reads
-- cache_reported_reqs requests where upstream gave a cache number at all.
-- Kept separate from a zero: "upstream does not report cache usage" and
-- "upstream reported zero hits" look identical in a hit total, and they mean
-- opposite things when you are trying to work out whether a cache discount is
-- doing anything.
local function emptyBucket()
return {
cost = 0, requests = 0, prompt_tokens = 0, completion_tokens = 0, failures = 0,
cache_hit_tokens = 0, cache_fresh_tokens = 0, cache_reported_reqs = 0,
}
end
plugin.state = {
total = emptyBucket(),
by_source = {},
by_model = {},
by_key = {},
by_day = {},
-- Day-keyed cost splits per dimension. Same shape as by_day, one level
-- deeper. Unbounded like by_day (days, not request rows), so this does not
-- grow with traffic — a year of days is 365 entries per dimension.
by_day_src = {},
by_day_model = {},
by_day_key = {},
started = os.time and 0 or 0,
}
local function bucket(tbl, k)
local b = tbl[k]
if b == nil then
b = emptyBucket()
tbl[k] = b
end
return b
end
-- dayMap returns the plain per-day SUB-TABLE of a nested dimension map, e.g.
-- by_day_src[day] -> { source -> bucket }.
--
-- bucket() cannot be reused for this level. It returns a BUCKET, so the
-- per-day container would come back carrying cost/requests/prompt_tokens keys
-- of its own, with the real source entries mixed in beside them. The period
-- view reads by_day_src[day] as "name -> bucket" and would then fold the
-- container's own fields as if they were sources.
local function dayMap(tbl, dk)
local m = tbl[dk]
if m == nil then
m = {}
tbl[dk] = m
end
return m
end
local function add(b, cost, prompt, completion, ok, cacheHit, cacheReported)
b.cost = b.cost + cost
b.requests = b.requests + 1
b.prompt_tokens = b.prompt_tokens + prompt
b.completion_tokens = b.completion_tokens + completion
if not ok then b.failures = b.failures + 1 end
-- Backfill guards a bucket that predates these fields (a state file written
-- by an older build, or one restored from disk): nil + number is an error in
-- Lua, and a hook that throws stops accounting for that request entirely.
if b.cache_hit_tokens == nil then b.cache_hit_tokens = 0 end
if b.cache_fresh_tokens == nil then b.cache_fresh_tokens = 0 end
if b.cache_reported_reqs == nil then b.cache_reported_reqs = 0 end
b.cache_hit_tokens = b.cache_hit_tokens + (cacheHit or 0)
b.cache_fresh_tokens = b.cache_fresh_tokens + ((prompt or 0) - (cacheHit or 0))
if cacheReported then b.cache_reported_reqs = b.cache_reported_reqs + 1 end
end
-- ---------- pricing ----------
-- lookup walks keys > models > default and returns a price triple plus whether
-- a flat per_request component applies.
local function priceFor(payload)
local p = plugin.prices or DEFAULT_PRICES
local d = p.default or {}
-- Whether ANY dimension actually priced this request. A request that ends up
-- with all-zero prices is not "free", it is UNPRICED, and the two must not
-- look the same: an unpriced model silently costing 0 is the most dangerous
-- failure mode a cost plugin has, because the bill still adds up and just
-- quietly under-reports. It is counted separately and surfaced in the UI.
out = {
prompt = d.prompt or 0, completion = d.completion or 0,
per_request = 0, cache_discount = d.cache_discount, peak = d.peak,
}
-- model dimension (a token price overrides the default's token prices)
local mp = p.models and p.models[payload.model]
if mp then
out.priced = true
if mp.prompt ~= nil then out.prompt = mp.prompt end
if mp.completion ~= nil then out.completion = mp.completion end
if mp.per_request ~= nil then out.per_request = out.per_request + mp.per_request end
if mp.cache_discount ~= nil then out.cache_discount = mp.cache_discount end
if mp.peak ~= nil then out.peak = mp.peak end
end
-- source dimension: usually a flat fee, but may also carry token prices
local sp = p.sources and p.sources[payload.source]
if sp then
out.priced = true
if sp.prompt ~= nil then out.prompt = sp.prompt end
if sp.completion ~= nil then out.completion = sp.completion end
if sp.per_request ~= nil then out.per_request = out.per_request + sp.per_request end
if sp.cache_discount ~= nil then out.cache_discount = sp.cache_discount end
if sp.peak ~= nil then out.peak = sp.peak end
end
-- key dimension wins over the others (an operator pricing one customer
-- specially must be able to override both the model and the source price)
local kp = p.keys and p.keys[payload.key]
if kp then
out.priced = true
if kp.prompt ~= nil then out.prompt = kp.prompt end
if kp.completion ~= nil then out.completion = kp.completion end
if kp.per_request ~= nil then out.per_request = out.per_request + kp.per_request end
if kp.cache_discount ~= nil then out.cache_discount = kp.cache_discount end
if kp.peak ~= nil then out.peak = kp.peak end
end
return out
end
-- ===== 峰谷 / 时段定价 ================================================
--
-- 有些 provider 按 UTC 时段分价(commandcode 的 DeepSeek V4 系列就是:高峰
-- 01-04 & 06-10 UTC 工作日,价格恰好是非高峰的 2 倍)。静态价目无法表达这一点,
-- 而算错方向通常是【静默高估或低估】,不会报错——所以这里显式支持。
--
-- 配置形态(挂在任一维度的价目条目上):
--
-- "deepseek-v4.1-flash": {
-- prompt = 1.5e-7, completion = 6e-7,
-- peak = {
-- multiplier = 2, -- 高峰时单价乘以它
-- windows = [ -- UTC 星期几 = os.date 的 %w(周日=1)
-- { days = {2,3,4,5,6}, hours = {{1,2,3},{6,7,8,9}} },
-- ],
-- },
-- }
--
-- 语义:命中任一 window ⇒ 乘以 multiplier。hours 用 {起,止} 闭区间,跨零点
-- 用 {{22,24}} 表示 22:00-24:00(24 是"当天最后一刻")。
--
-- ★ 为什么用 os.date 的 ! 前缀取 UTC:provider 的费率表按 UTC 标注,而网关
-- 跑在本地时区(这台机是 Asia/Hong_Kong)。混用本地小时会让峰谷整体偏移 8
-- 小时,白天算成夜间——比不做峰谷还糟。
local function inPeakWindow(ev)
if ev == nil then return false end
local w = ev.windows
if type(w) ~= "table" or #w == 0 then return false end
local dow = tonumber(os.date("!%w")) or 0 -- 0=Sunday
local hour = tonumber(os.date("!%H")) or 0
for _, win in ipairs(w) do
local days = win.days
if type(days) == "table" then
local day_ok = false
for _, d in ipairs(days) do
if tonumber(d) == dow then day_ok = true break end
end
if not day_ok then goto continue_win end
end
local hours = win.hours
if type(hours) == "table" then
for _, h in ipairs(hours) do
local lo, hi = tonumber(h[1]), tonumber(h[2])
if lo and hi and hour >= lo and hour <= hi then return true end
end
end
::continue_win::
end
return false
end
-- applyPeak multiplies a price by the peak rule, if the request lands in a peak
-- window. It is a no-op when no rule is configured, so the common case costs one
-- nil check.
--
-- The multiplier is RECORDED, not applied to price.prompt in place. That looks
-- like a roundabout way to do it, but applying it there was a real bug: the
-- cache-read rate is DERIVED from price.prompt inside costFor, so doubling
-- price.prompt silently doubled the cache read too — compounding two separate
-- discounts. Keeping the multiplier separate lets costFor scale the fresh-prompt
-- and completion legs and leave the cache leg alone, which is what "peak rates
-- apply to the token price, cache reads are billed at their own rate" means.
local function applyPeak(price)
local pk = price.peak
if pk == nil then return price end
if not inPeakWindow(pk) then return price end
local m = tonumber(pk.multiplier) or 1
if m <= 0 then return price end
price.peak_multiplier = m
return price
end
-- costFor computes one request's price.
--
-- PROMPT CACHE: a cached prompt token is not billed like a fresh one. Almost
-- every provider sells cache reads at a steep discount (commonly 10% of the
-- fresh rate), and cache-heavy agent traffic hits long shared prefixes hard.
-- Charging the full prompt rate made a 1M-token request of which 900k were
-- cache reads come out at 10 USD instead of ~1.9 — an order of magnitude, on
-- exactly the traffic the cache exists to make cheap. The plugin therefore
-- splits the prompt count:
--
-- fresh = prompt_tokens - cache_hit_tokens -> full rate
-- cached = cache_hit_tokens -> rate * cache_discount
--
-- cache_discount defaults to 0.1 (the common 10x). It is configurable because
-- the ratio is a per-provider fact, not a constant of nature: set it to 1 to
-- keep the old flat behaviour, or 0 for providers that do not discount.
--
-- A request that reports cache_hit_tokens LARGER than prompt_tokens (a
-- misbehaving adapter, or two upstreams' numbers being mixed) is clamped: the
-- fresh count never goes negative, which would silently turn a request into
-- billable negative tokens.
local function costFor(payload, price)
price = applyPeak(price or priceFor(payload))
local prompt = tonumber(payload.prompt_tokens) or 0
local completion = tonumber(payload.completion_tokens) or 0
local cacheHit = tonumber(payload.cache_hit_tokens) or 0
if cacheHit < 0 then cacheHit = 0 end
if cacheHit > prompt then cacheHit = prompt end
local discount = tonumber(price.cache_discount)
if discount == nil then discount = plugin.cache_discount end
if discount == nil then discount = 0.1 end
if discount < 0 then discount = 0 elseif discount > 1 then discount = 1 end
-- The peak multiplier applies to the freshly-read prompt tokens and the
-- completion, but NOT to the cache read: a cache read is a separate upstream
-- rate that the off-peak figures already discount, and doubling it would
-- stack two discounts the provider never intended to stack.
local mult = tonumber(price.peak_multiplier) or 1
local fresh = prompt - cacheHit
local cost = fresh * price.prompt * mult
+ cacheHit * price.prompt * discount
+ completion * price.completion * mult
local flat = price.per_request
if not payload.ok and not plugin.count_failures then
flat = 0
end
return cost + flat
end
-- ---------- day bucket ----------
local function dayKey(epoch_seconds)
-- os.date is available in LuaJIT; fall back to a UTC-ish arithmetic stamp if
-- the host build has no os.date (keeps the plugin from erroring out on a
-- stripped runtime, which would otherwise look like a plugin failure).
if os and os.date then
return os.date("!%Y-%m-%d", epoch_seconds)
end
return tostring(math.floor(epoch_seconds / 86400))
end
-- ---------- hooks ----------
plugin.hooks = {
-- chain_step gives the per-tier walk; request_end gives the final accounting.
-- Subscribing to chain_step is OPTIONAL here: the totals are driven by
-- request_end alone, and the degradation counters below are pure observation.
-- A gateway with thousands of requests can drop this hook to save the
-- per-step Lua call without losing a single billed request.
chain_step = "on_chain_step",
request_end = "on_request_end",
}
-- Tracks how often a request had to drop below the top tier, and which tier
-- actually served it. Without this, "tier 1 was cooling" and "tier 1 served it"
-- are indistinguishable in the accounts, and a quietly degraded gateway looks
-- exactly like a healthy one.
plugin.state.degraded_reqs = 0
plugin.state.by_tier_served = {}
plugin.state.skip_reasons = {}
function plugin.on_chain_step(payload)
if payload == nil then return nil end
local s = plugin.state
if s == nil then return nil end
if s.by_tier_served == nil then s.by_tier_served = {} end
if s.skip_reasons == nil then s.skip_reasons = {} end
if payload.kind == "selected" then
local t = tostring(payload.tier or "?")
s.by_tier_served[t] = (s.by_tier_served[t] or 0) + 1
elseif payload.kind == "tier_skip" or payload.kind == "tier_busy" then
-- reason text is the ACTIONABLE part; normalise the volatile bits so the
-- same cause aggregates instead of creating a new row per request.
local r = tostring(payload.reason or payload.kind or "unknown")
r = string.gsub(r, "within [%d%.%a]+", "within <wait>")
s.skip_reasons[r] = (s.skip_reasons[r] or 0) + 1
end
return nil
end
function plugin.on_request_end(payload)
if payload == nil then return nil end
local prompt = tonumber(payload.prompt_tokens) or 0
local completion = tonumber(payload.completion_tokens) or 0
local ok = payload.ok and true or false
local price = priceFor(payload)
local cost = costFor(payload, price)
local s = plugin.state
-- Rebuild any missing container. This is reached in two real situations:
-- a fresh plugin, and an admin who PUT a partial state (e.g. only "prices"),
-- which legitimately replaces `state` with a sparse table. Checking only the
-- outer table would leave `s.total` nil and crash the hook on the next call.
if s == nil then s = {} plugin.state = s end
if s.total == nil then s.total = emptyBucket() end
if s.by_source == nil then s.by_source = {} end
if s.by_model == nil then s.by_model = {} end
if s.by_key == nil then s.by_key = {} end
if s.by_day == nil then s.by_day = {} end
-- Day-keyed dimension splits: backfilled for state files written before they
-- existed. They stay empty until new traffic arrives, which makes the
-- period view show "no data yet" for those days rather than silently
-- reporting a zero cost for a day that actually cost money.
if s.by_day_src == nil then s.by_day_src = {} end
if s.by_day_model == nil then s.by_day_model = {} end
if s.by_day_key == nil then s.by_day_key = {} end
if s.started == nil then s.started = payload.time or 0 end
if s.unpriced_reqs == nil then s.unpriced_reqs = 0 end
if s.unpriced_models == nil then s.unpriced_models = {} end
-- Track traffic that no price entry covered. This MUST come after the
-- container rebuild above: an earlier version referenced `s` before it was
-- declared, so on a fresh plugin the hook threw and the request recorded
-- NOTHING at all — the worst possible failure for a billing plugin, and one
-- that only showed up as "requests = 0" in a test.
if not price.priced then
s.unpriced_reqs = s.unpriced_reqs + 1
local m = payload.model or "?"
s.unpriced_models[m] = (s.unpriced_models[m] or 0) + 1
end
if s.degraded_reqs == nil then s.degraded_reqs = 0 end
-- Degradation is counted here rather than in the chain_step hook because
-- request_end sees the whole walk at once: one degraded request must count
-- once, whereas the walk may contain several skipped tiers.
if payload.degraded then s.degraded_reqs = s.degraded_reqs + 1 end
local cacheHit = tonumber(payload.cache_hit_tokens) or 0
if cacheHit < 0 then cacheHit = 0 end
if cacheHit > prompt then cacheHit = prompt end
-- cache_reported is the gateway's own signal that UPSTREAM gave a cache
-- number. Without it a source that never reports cache usage is
-- indistinguishable from one that always reports zero hits.
local cacheReported = payload.cache_reported and true or false
local C = cacheHit
local R = cacheReported
add(s.total, cost, prompt, completion, ok, C, R)
if payload.source ~= nil and payload.source ~= "" then
add(bucket(s.by_source, payload.source), cost, prompt, completion, ok, C, R)
end
if payload.model ~= nil and payload.model ~= "" then
add(bucket(s.by_model, payload.model), cost, prompt, completion, ok, C, R)
end
if payload.key ~= nil and payload.key ~= "" then
add(bucket(s.by_key, payload.key), cost, prompt, completion, ok, C, R)
end
-- Daily rollup, so the dashboard can draw a trend without the browser
-- re-deriving it. Keyed off the request's own timestamp, not os.time(), so a
-- replayed or imported record lands on the right day.
--
-- by_day_src/model/key carry the SAME cost split per day. Without them the
-- period selector could only rescale the headline total, while every
-- dimension table kept showing lifetime figures — the page would answer
-- "how much did I spend today?" with an all-time table next to a today
-- total, and the two would not add up. Cost is folded per day at write
-- time so the browser never re-prices anything.
local ts = payload.time
if ts ~= nil and ts > 0 then
if ts > 1000000000000 then ts = ts / 1000 end -- kernel sends unix MILLIseconds
local dk = dayKey(ts)
add(bucket(s.by_day, dk), cost, prompt, completion, ok, C, R)
if payload.source ~= nil and payload.source ~= "" then
add(bucket(dayMap(s.by_day_src, dk), payload.source), cost, prompt, completion, ok, C, R)
end
if payload.model ~= nil and payload.model ~= "" then
add(bucket(dayMap(s.by_day_model, dk), payload.model), cost, prompt, completion, ok, C, R)
end
if payload.key ~= nil and payload.key ~= "" then
add(bucket(dayMap(s.by_day_key, dk), payload.key), cost, prompt, completion, ok, C, R)
end
end
return nil -- last stage: nobody downstream would read a return value
end
-- ---------- dashboard UI ----------
-- A whole page. The kernel injects this HTML and evaluates the <script> after
-- the DOM exists, and exposes `pluginAPI` for talking to the gateway.
plugin.ui = {
page = {
page_id = "billing",
title = "Billing",
icon = [==[<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M14.5 9.5a3 3 0 0 0-2.5-1.3c-1.4 0-2.4.7-2.4 1.8 0 2.6 5.2 1.4 5.2 4 0 1.1-1 1.8-2.5 1.8-1.1 0-2.1-.4-2.7-1.2"/><path d="M12 6.4v11.2"/></svg>]==],
order = 40,
mount = [==[
<div id="billing-root" style="padding:16px;min-width:0;max-width:100%;overflow-x:auto">
<!-- 视图切换:用量 / 规则。
之前拆成两个侧栏页是我判断失误——计费本来是一个整体,改完价格要翻到
另一个页面才看得到效果。放在同一个页面里切换,规则编辑完立刻就能看到
统计变化。 -->
<div class="filter-line" style="margin:0 0 12px">
<button id="billing-view-usage-btn" class="ghost small" onclick="window.__billing_view('usage')">usage</button>
<button id="billing-view-rules-btn" class="ghost small" onclick="window.__billing_view('rules')">rules</button>
<span class="grow"></span>
<span id="billing-view-msg" class="muted" style="font-size:12px"></span>
</div>
<!-- 两个视图容器都要 flex:1;width:100%。
宿主的 .tab-pane 是 display:flex 列容器,子元素默认按内容宽度收缩——不写
flex:1 时 rules 视图被压成 72px,里面的表格宽度 0,整页看着像没渲染。
这是我合并两页时踩的坑:旧的两个页面各自是独立的 pane,不存在这个问题。 -->
<div id="billing-view-usage" style="flex:1;min-width:0;width:100%">
<div class="filter-line" style="margin:0 0 12px">
<span class="muted" id="billing-period-label"></span>
<select id="billing-period" onchange="window.__billing_setPeriod(this.value)">
<option value="day">day</option><option value="week">week</option>
<option value="month">month</option><option value="all">all</option>
</select>
</div>
<div class="kpis" id="billing-kpis" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:18px"></div>
<!-- 维度表每行 8 列(名称+开销+请求+输入+新鲜+缓存+缓存%+输出),下限必须
>=340px:我在 260px 时把数字排成竖着的单字符列(USD 0.56 → "U/S/D"),
填满了宽度却没法看。2 列 × 340px 刚好排满 946px,第三张(按天)横跨整行。 -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:16px">
<div class="card" style="padding:14px">
<h3 id="billing-h-src" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-source"></div>
</div>
<div class="card" style="padding:14px">
<h3 id="billing-h-model" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-model"></div>
</div>
<div class="card" style="padding:14px">
<h3 id="billing-h-key" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-key"></div>
</div>
</div>
<!-- 按天表横跨整行:网格里 4 张卡是 2×2,按天是最后一张,落在左下角,
右边空一块。grid-column:1/-1 让它单独占满一行,宽度也够放下日期列。 -->
<div class="card" style="padding:14px;margin-top:16px;grid-column:1/-1">
<h3 id="billing-h-day" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-day"></div>
</div>
</div>
<div id="billing-view-rules" style="display:none;flex:1;min-width:0;width:100%">
<div id="br-body"><div class="muted">…</div></div>
</div>
</div>
<script>
(function () {
var ROOT = "billing";
// UI strings, bilingual. The host page i18n (applyI18n/data-i) only covers
// markup the HOST renders; a plugin's injected markup is invisible to it, so
// the Billing page stayed English while the rest of the UI switched. These go
// through pluginAPI.lang / onLangChange, the small surface the host exposes
// for exactly this.
var STR = {
en: {
total: "Total", requests: "Requests", degraded: "Degraded",
unpriced: "Unpriced", prompt: "Prompt tokens",
completion: "Completion tokens", failures: "Failures",
cacheRate: "Cache hit rate", cacheTokens: "Cache read tokens",
perSource: "Per source", perModel: "Per model",
perKey: "Per gateway key", perDay: "Daily",
thName: "name", thCost: "cost", thReqs: "reqs", thPrompt: "prompt",
thFresh: "fresh", thCache: "cache", thCachePct: "cache%",
thCompletion: "completion",
// 8 columns do not fit the 465px a two-up grid gives each card: measured
// 23 of 80 cells overflowing with table-layout:fixed. The four token
// sub-columns (prompt / fresh / cache / cache%) collapse into ONE column
// "in (fresh+cache)" with the breakdown in its tooltip, so the numbers a
// reader actually compares stay on screen.
thInSplit: "in (fresh+cache)",
noData: "no data yet",
periodLabel: "Period", periodDay: "Today", periodWeek: "This week",
periodMonth: "This month", periodAll: "All time",
periodNote: "degraded / unpriced are lifetime totals",
},
zh: {
total: "总开销", requests: "请求数", degraded: "降级",
unpriced: "未定价", prompt: "输入 tokens",
completion: "输出 tokens", failures: "失败",
cacheRate: "缓存命中率", cacheTokens: "缓存读取 tokens",
perSource: "按源", perModel: "按模型",
perKey: "按网关密钥", perDay: "按天",
thName: "名称", thCost: "开销", thReqs: "请求", thPrompt: "输入",
thFresh: "新鲜", thCache: "缓存", thCachePct: "缓存%",
thCompletion: "输出",
thInSplit: "输入(新鲜+缓存)",
noData: "暂无数据",
periodLabel: "统计周期", periodDay: "今日", periodWeek: "本周",
periodMonth: "本月", periodAll: "全部",
periodNote: "降级/未定价为终身累计",
},
};
function L() {
var lang = (window.pluginAPI && pluginAPI.lang) || "zh";
return STR[lang] || STR.zh;
}
function fmt(n) {
if (n === null || n === undefined) return "-";
n = Number(n);
if (!isFinite(n)) return "-";
if (n === 0) return "0";
if (Math.abs(n) < 0.000001) return n.toExponential(2);
return n.toFixed(Math.abs(n) < 1 ? 6 : 4);
}
function money(v, cur) { return (cur || "USD") + " " + fmt(v); }
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
// Cache hit rate, with the reporting caveat made visible.
//
// A bucket whose upstream never reports cache usage would render as "0%" from
// a 0/0 and read as "the cache is not working", when the truth is "this
// provider does not tell us". "n/r" keeps those apart.
function cacheRate(b) {
var prompt = b.prompt_tokens || 0;
var hit = b.cache_hit_tokens || 0;
if (!prompt) return "\u2014";
if (!b.cache_reported_reqs) return "n/r";
return ((hit / prompt) * 100).toFixed(1) + "%";
}
// fmtInt 千分位分组:2e8 级 token 总数可读、也更短,降低撑宽风险。
function fmtInt(n) {
return (Number(n) || 0).toLocaleString("en-US");
}
// ---- period rescaling ----
//
// "all" is the stored state as-is. For day/week/month we re-sum the
// day-keyed buckets the hook maintains. Summing, never re-pricing: the cost
// was already decided per request at write time, and re-deriving it in the
// browser would let the displayed cost drift from the persisted one.
//
// UTC throughout, matching the gateway's own dayKey and its period windows,
// so a peak-rate hour cannot land in one day here and another in /api/stats.
var PERIOD = "all";
function utcToday() {
return new Date().toISOString().slice(0, 10);
}
function daysInWindow(period, todayStr) {
// Return the set of YYYY-MM-DD keys the window covers, as a lookup table.
var now = new Date(todayStr + "T00:00:00Z");
var out = {};
if (period === "day") {
out[todayStr] = 1;
return out;
}
if (period === "month") {
var y = now.getUTCFullYear(), m = now.getUTCMonth();
for (var d = 1; d <= 31; d++) {
var k = y + "-" + ("0" + (m + 1)).slice(-2) + "-" + ("0" + d).slice(-2);
if (new Date(k + "T00:00:00Z").getUTCMonth() !== m) break;
out[k] = 1;
}
return out;
}
if (period === "week") {
// ISO week starts Monday. getUTCDay() is 0=Sunday, so shift by 6 mod 7.
var back = (now.getUTCDay() + 6) % 7;
for (var i = 0; i < 7; i++) {
var t = new Date(now.getTime() - (back - i) * 86400000);
out[t.toISOString().slice(0, 10)] = 1;
}
return out;
}
return null; // all
}
function zeroBucket() {
return { cost: 0, requests: 0, prompt_tokens: 0, completion_tokens: 0,
failures: 0, cache_hit_tokens: 0, cache_fresh_tokens: 0,
cache_reported_reqs: 0 };
}
function foldInto(dst, src) {
if (!src) return dst;
dst.cost += Number(src.cost || 0);
dst.requests += Number(src.requests || 0);
dst.prompt_tokens += Number(src.prompt_tokens || 0);
dst.completion_tokens += Number(src.completion_tokens || 0);
dst.failures += Number(src.failures || 0);
dst.cache_hit_tokens += Number(src.cache_hit_tokens || 0);
dst.cache_fresh_tokens += Number(src.cache_fresh_tokens || 0);
dst.cache_reported_reqs += Number(src.cache_reported_reqs || 0);
return dst;
}
// foldDim sums {day: {name: bucket}} into {name: bucket} for the window.
function foldDim(byDayDim, days) {
var out = {};
if (!byDayDim) return out;
Object.keys(byDayDim).forEach(function (day) {
if (!days[day]) return;
var dims = byDayDim[day] || {};
Object.keys(dims).forEach(function (name) {
out[name] = foldInto(out[name] || zeroBucket(), dims[name]);
});
});
return out;
}
// rescale returns the state as the selected period presents it. Degraded and
// unpriced counters are lifetime-only (there is no day-keyed tally for them)
// so they are returned as null and the UI hides them instead of printing a
// lifetime number beside a per-window cost — the exact mismatch the period
// view exists to remove.
function rescale(st) {
if (PERIOD === "all" || !st.by_day) return st;
var days = daysInWindow(PERIOD, utcToday());
var total = zeroBucket();
var byDay = {};
Object.keys(st.by_day).forEach(function (day) {
if (!days[day]) return;
foldInto(total, st.by_day[day]);
byDay[day] = st.by_day[day];
});
var out = {
currency: st.currency,
total: total,
by_source: foldDim(st.by_day_src, days),
by_model: foldDim(st.by_day_model, days),
by_key: foldDim(st.by_day_key, days),
by_day: byDay,
degraded_reqs: null,
unpriced_reqs: null,
};
return out;
}
function row(name, b, cur) {
var fresh = (b.cache_fresh_tokens === undefined) ? (b.prompt_tokens || 0) : b.cache_fresh_tokens;
// 名称列 ellipsis(title 悬停看全名);数字列不折行。
//
// 之前数字列用 word-break:break-all “避免撑破卡片”,实测后果是每个数字
// 字符独占一行(USD 0.56 变成 “U/S/D” 竖着排),整张表没法读。列宽不足
// 时正确的做法是让表格横向滚动(外层已有 overflow-x:auto),而不是把
// 数字椎成竖排。th 同理:表头也参与同一套列宽。
var detail = "prompt " + fmtInt(b.prompt_tokens || 0) +
" / fresh " + fmtInt(fresh) +
" / cache " + fmtInt(b.cache_hit_tokens || 0) +
" / rate " + cacheRate(b);
return "<tr><td style='max-width:150px;overflow:hidden'><b style='display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis' title='" +
esc(String(name).replace(/'/g, "&#39;")) + "'>" + esc(name) + "</b></td>" +
"<td class='bnum'>" + money(b.cost, cur) + "</td>" +
"<td class='bnum'>" + fmtInt(b.requests || 0) + "</td>" +
"<td class='bnum' title='" + esc(detail) + "'>" + fmtInt(b.prompt_tokens || 0) + "</td>" +
"<td class='bnum' title='" + esc(cacheRate(b)) + "'>" + esc(cacheRate(b)) + "</td>" +
"<td class='bnum'>" + fmtInt(b.completion_tokens || 0) + "</td></tr>";
}
function tableFor(el, obj, cur, empty) {
var keys = Object.keys(obj || {});
if (!keys.length) { el.innerHTML = '<div class="muted">' + empty + "</div>"; return; }
keys.sort(function (a, b) { return (obj[b].cost || 0) - (obj[a].cost || 0); });
var TH = L();
// table-layout:auto, not fixed: fixed divided the 465px card into 62px
// columns and the numbers overlapped each other. auto lets the browser size
// columns by content, and min-width keeps the table readable by scrolling
// sideways instead of crushing every cell.
var h = "<style>.bnum{white-space:nowrap;font-variant-numeric:tabular-nums}</style>" +
"<div style='overflow-x:auto'>" +
"<table style='width:100%;border-collapse:collapse;font-size:13px;min-width:400px'>" +
"<tr style='text-align:left;opacity:.65'><th>" + TH.thName + "</th><th>" + TH.thCost +
"</th><th>" + TH.thReqs + "</th><th>" + TH.thInSplit +
"</th><th>" + TH.thCachePct + "</th><th>" + TH.thCompletion + "</th></tr>";
for (var i = 0; i < keys.length; i++) {
var k = keys[i];
h += "<tr style='border-top:1px solid rgba(120,90,150,.14)'>" + row(k, obj[k], cur) + "</tr>";
}
el.innerHTML = h + "</table></div>";
}
function renderTitles() {
var T = L();
var m = { "billing-h-src": T.perSource, "billing-h-model": T.perModel,
"billing-h-key": T.perKey, "billing-h-day": T.perDay,
"billing-period-label": T.periodLabel };
for (var id in m) {
var el = document.getElementById(id);
if (el) el.textContent = m[id];
}
// The <select> is mounted markup the host's data-i pass never sees, so its
// option labels are translated here too.
var sel = document.getElementById("billing-period");
if (sel && sel.options) {
var names = { day: T.periodDay, week: T.periodWeek,
month: T.periodMonth, all: T.periodAll };
for (var i = 0; i < sel.options.length; i++) {
var o = sel.options[i];
if (names[o.value]) o.textContent = names[o.value];
}
sel.value = PERIOD;
}
}
function render(st) {
if (!st) return;
renderTitles();
var T = L();
var cur = (st.currency || "USD");
var view = rescale(st);
var t = view.total || {};
document.getElementById("billing-kpis").innerHTML = [
[T.total, money(t.cost, cur)],
[T.requests, fmtInt(t.requests || 0)],
// null = lifetime-only counter with no day-keyed tally; hide rather than
// print a lifetime number next to a per-window cost.
view.degraded_reqs != null ? [T.degraded, fmtInt(view.degraded_reqs)] : null,
view.unpriced_reqs != null ? [T.unpriced, fmtInt(view.unpriced_reqs)] : null,
[T.prompt, fmtInt(t.prompt_tokens || 0)],
[T.completion, fmtInt(t.completion_tokens || 0)],
[T.failures, t.failures || 0],
// Cache KPIs: last session added the table columns but the KPI cards
// were left out — the edit's assert failed and the retry only re-did the
// tables. The numbers existed in state and nowhere in the UI.
[T.cacheRate, cacheRate(t)],
[T.cacheTokens, fmtInt(t.cache_hit_tokens || 0)]
].filter(function (x) { return x !== null; }).map(function (kv) {
// min-width:0:grid item 默认 min-width:auto,2e8 级长数字会把轨道撑出
// 容器造成横向溢出。标签 nowrap 截断,数值 break-all 换行。
return "<div class='card' style='padding:12px;min-width:0;overflow:hidden'>" +
"<div style='font-size:11px;opacity:.65;white-space:nowrap;overflow:hidden;text-overflow:ellipsis'>" +
esc(kv[0]) + "</div><div style='font-size:19px;font-weight:600;margin-top:4px;word-break:break-all;line-height:1.2'>" +
esc(kv[1]) + "</div></div>";
}).join("");
var TD = L();
tableFor(document.getElementById("billing-by-source"), view.by_source, cur, TD.noData);
tableFor(document.getElementById("billing-by-model"), view.by_model, cur, TD.noData);
tableFor(document.getElementById("billing-by-key"), view.by_key, cur, TD.noData);
tableFor(document.getElementById("billing-by-day"), view.by_day, cur, TD.noData);
}
async function refresh() {
try {
var r = await fetch("/api/plugins/" + ROOT + "/state", { credentials: "same-origin" });
if (!r.ok) return;
var j = await r.json();
render(j.state);
} catch (e) {
// Swallowing this is what made the production bug invisible: render() threw
// a ReferenceError on an undefined `s`, the catch ate it, every table kept
// its empty placeholder, and the page looked fine in the network tab while
// showing nothing. Still must not THROW (the pane is decoration and must
// never break the host page) — but it must leave a trace.
if (window.console && console.error) console.error("[billing] render failed", e);
}
}
window.__billingRefresh = refresh;
window.__billing_setPeriod = function (v) {
if (["day", "week", "month", "all"].indexOf(v) < 0) return;
PERIOD = v;
renderTitles();
refresh();
};
// ---- 视图切换(用量 / 规则)----
//
// 计费是一个整体:价格规则决定数字,数字是价格规则的结果。拆成两个侧栏页
// 时改完价格要翻到另一个页面才知道有没有生效,等于把一个闭环切断。
//
// 两页共用同一个侧栏入口,但规则编辑器改价后会立即重拉统计(rules 页的
// save() 末尾就调了 __billingRefresh),切回来就能看到变化。
var VIEWS = ["usage", "rules"];
function viewLabel(v) {
var zh = { usage: "用量", rules: "计费规则" };
var en = { usage: "Usage", rules: "Price rules" };
var lang = (window.pluginAPI && pluginAPI.lang) === "en" ? "en" : "zh";
return (lang === "en" ? en : zh)[v] || v;
}
function paintView() {
VIEWS.forEach(function (v) {
var pane = document.getElementById("billing-view-" + v);
if (pane) pane.style.display = v === VIEW ? "" : "none";
var btn = document.getElementById("billing-view-" + v + "-btn");
// The active button is filled, the other ghosted — the host's own
// segmented control uses the same convention, so it reads as native.
if (btn) btn.className = (v === VIEW ? "" : "ghost ") + "small";
if (btn) btn.textContent = viewLabel(v);
});
}
var VIEW = "usage";
window.__billing_view = function (v) {
if (VIEWS.indexOf(v) < 0) return;
VIEW = v;
paintView();
if (v === "rules" && window.__billingRulesLoad) window.__billingRulesLoad();
};
paintView();
// Test-only hook for the period logic. The helpers live in this IIFE, so a
// Go test driving the real code cannot reach them otherwise; without it the
// only option is to reimplement the window rules in the test, which would
// prove the reimplementation agrees with itself. Read-only exposure of pure
// functions — it grants no capability the page does not already have.
window.__billingInternals = {
daysInWindow: daysInWindow,
rescale: rescale,
currentPeriod: function () { return PERIOD; },
};
refresh();
if (window.pluginAPI) {
if (pluginAPI.onTabShown) pluginAPI.onTabShown(refresh);
if (pluginAPI.onLangChange) {
pluginAPI.onLangChange(function () {
renderTitles();
paintView();
refresh();
});
}
}
})();
(function () {
var API = "/api/plugins/billing/rules";
var STR = {
en: {
title: "Billing rules", profile: "Profile", newProfile: "New profile",
addRule: "Add rule", delRule: "Delete", exportYaml: "Export YAML",
url: "Source URL", mode: "Mode", prompt: "Prompt $/M",
completion: "Completion $/M", currency: "Currency",
monthly: "Monthly fee", peakMult: "Peak multiplier",
peakHours: "Peak hours (UTC)", peakDays: "Peak days (UTC 1=Mon)",
models: "Per-model prices", addModel: "+ model",
mFree: "free", mToken: "token", mSub: "subscription", mUnpriced: "unpriced",
noProfile: "No profile yet — create one to start pricing.",
noRules: "No rules in this profile",
warnNoSource: "matches no configured source",
active: "ACTIVE", setActive: "Set active",
saving: "saving…", saved: "Saved", rejected: "Rejected", save: "Save",
newProfileId: "New profile id",
needUrl: "a rule needs a URL", needPrices: "every model needs both prices",
badPeakHours: "peak hours look like 1-4,6-10 (UTC, 0-23)",
badPeakDays: "peak days are UTC weekday numbers 0-6",
peakNeedsMult: "peak hours need a multiplier",
adminOnly: "Billing rules are admin-only.",
activePrices: "Prices now in effect",
},
zh: {
title: "计费规则", profile: "方案", newProfile: "新建方案",
addRule: "新增规则", delRule: "删除", exportYaml: "导出 YAML",
url: "源 URL", mode: "计费模式", prompt: "输入 $/M",
completion: "输出 $/M", currency: "币种",
monthly: "月费", peakMult: "峰段倍数",
peakHours: "峰段小时 (UTC)", peakDays: "峰段星期 (UTC 1=周一)",
models: "按模型价格", addModel: "+ 模型",
mFree: "free 免费", mToken: "token 按量",
mSub: "subscription 订阅", mUnpriced: "unpriced 只统计",
noProfile: "还没有方案 —— 新建一个开始定价。",
noRules: "该方案暂无规则",
warnNoSource: "匹配不到已配置的源",
active: "生效中", setActive: "设为生效",
saving: "保存中…", saved: "已保存", rejected: "被拒绝", save: "保存",
newProfileId: "新方案 id",
needUrl: "规则必须有 URL",
needPrices: "每个模型都要填输入和输出价格",
badPeakHours: "峰段小时应形如 1-4,6-10(UTC,0-23)",
badPeakDays: "峰段星期应为 UTC 星期编号 0-6",
peakNeedsMult: "填了峰段小时就要同时填倍数",
adminOnly: "计费规则仅限管理员。",
activePrices: "当前生效的价格",
},
};
function L() {
var lang = (window.pluginAPI && pluginAPI.lang) === "en" ? "en" : "zh";
return STR[lang] || STR.zh;
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
function call(method, body) {
return fetch(API, {
method: method,
credentials: "same-origin",
headers: body ? { "Content-Type": "application/json" } : undefined,
body: body ? JSON.stringify(body) : undefined,
}).then(function (r) {
return r.json().catch(function () { return {}; }).then(function (j) {
if (!r.ok) throw new Error((j.error && j.error.message) || ("HTTP " + r.status));
return j;
});
});
}
var DATA = null;
// ---- peak window parsing -------------------------------------------------
//
// Parsed here as well as server-side so the operator is told WHICH row is
// wrong instead of getting a 400 that names a field. A malformed peak window
// that is silently dropped would bill peak traffic at off-peak rates, which
// is the failure this whole page exists to make impossible.
function parseHours(txt) {
txt = String(txt || "").trim();
if (!txt) return [];
var out = [];
var parts = txt.split(",");
for (var i = 0; i < parts.length; i++) {
var m = String(parts[i]).trim().match(/^(\d{1,2})\s*-\s*(\d{1,2})$/);
if (!m) return null;
var lo = parseInt(m[1], 10), hi = parseInt(m[2], 10);
if (lo < 0 || hi > 23 || lo > hi) return null;
out.push([lo, hi]);
}
return out;
}
function parseDays(txt) {
txt = String(txt || "").trim();
if (!txt) return [];
var out = [];
var parts = txt.split(",");
for (var i = 0; i < parts.length; i++) {
var n = parseInt(String(parts[i]).trim(), 10);
if (isNaN(n) || n < 0 || n > 6) return null;
out.push(n);
}
return out;
}
function hoursText(p) {
if (!p || !p.hours) return "";
return p.hours.map(function (h) { return h[0] + "-" + h[1]; }).join(",");
}
function daysText(p) {
if (!p || !p.weekdays || !p.weekdays.length) return "";
return p.weekdays.join(",");
}
// ---- row rendering -------------------------------------------------------
function modelBlock(models, T) {
var keys = Object.keys(models || {});
// Every child must be allowed to SHRINK: the price cell is 262px wide and
// the four parts (name + prompt + completion + delete) used to be
// 80+96+96+38 plus gaps = ~330px, so the × button overflowed the cell by
// 78px and landed on top of the neighbouring column's Delete button.
// flex:1 1 0 with min-width:0 lets the inputs share the cell instead of
// pushing past it; the row wraps rather than overlapping.
var rows = keys.map(function (m) {
var t = models[m] || {};
return "<div class='row' style='gap:4px;margin:3px 0;flex-wrap:wrap'>" +
"<input class='m-name' value='" + esc(m) + "' style='flex:1 1 90px;min-width:0'>" +
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
"' placeholder='" + esc(T.prompt) + "' style='flex:0 1 62px;min-width:0'>" +
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
"' placeholder='" + esc(T.completion) + "' style='flex:0 1 62px;min-width:0'>" +
"<button class='ghost small m-del' title='" + esc(T.delRule) + "' style='flex:0 0 auto'>&times;</button></div>";
}).join("");
return "<div class='m-rows'>" + rows + "</div>" +
"<button class='ghost small m-add'>" + esc(T.addModel) + "</button>";
}
function ruleRow(rule, known, T) {
var unknown = rule.url !== "*" && known.indexOf(rule.url) < 0;
var opts = [["free", T.mFree], ["token", T.mToken], ["subscription", T.mSub], ["unpriced", T.mUnpriced]]
.map(function (o) {
return "<option value='" + o[0] + "'" + (rule.mode === o[0] ? " selected" : "") + ">" + esc(o[1]) + "</option>";
}).join("");
var p = rule.peak;
// 每个单元格的内容都必须 box-sizing:border-box + min-width:0,否则 input
// 的固有宽度会把列撑开:实测表格 986px 挤在 954px 容器里,最右侧的
// 「删除」列被切掉 32px,按钮点不到;r-cur/r-sub 这些只有 style='width:74px'
// 的输入框实测只有 40px 可用,几乎不能输入。
return "<tr class='br-rule' data-url='" + esc(rule.url || "") + "'>" +
"<td style='min-width:200px'><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%;box-sizing:border-box'>" +
(unknown ? "<div style='color:#e0a33e;font-size:11px'>&#9888; " + esc(T.warnNoSource) + "</div>" : "") +
"</td>" +
"<td style='width:88px'><select class='r-mode' style='width:100%;box-sizing:border-box;padding:2px;font-size:12px'>" + opts + "</select></td>" +
"<td style='width:70px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:80px'><input class='r-sub' type='number' step='0.01' value='" +
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:68px'><input class='r-mult' type='number' step='0.1' value='" +
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:96px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='width:78px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3' style='width:100%;box-sizing:border-box;padding:2px 4px;font-size:12px'></td>" +
"<td style='min-width:180px'>" + modelBlock(rule.models, T) + "</td>" +
"<td style='width:56px'><button class='ghost small r-del'>" + esc(T.delRule) + "</button></td></tr>";
}
// ---- collecting one rule back out of the DOM ----------------------------
function collectRule(tr, T) {
var url = tr.querySelector(".r-url").value.trim();
if (!url) throw new Error(T.needUrl);
var mode = tr.querySelector(".r-mode").value;
var out = { url: url, mode: mode };
var cur = tr.querySelector(".r-cur").value.trim();
if (cur) out.currency = cur;
if (mode === "subscription") {
var sub = parseFloat(tr.querySelector(".r-sub").value);
if (!isNaN(sub)) out.subscription = sub;
}
var hoursTxt = tr.querySelector(".r-hours").value.trim();
var daysTxt = tr.querySelector(".r-days").value.trim();
var mult = parseFloat(tr.querySelector(".r-mult").value);
if (hoursTxt || daysTxt || (!isNaN(mult) && mult > 0)) {
if (isNaN(mult) || mult <= 0) throw new Error(T.peakNeedsMult);
var hours = parseHours(hoursTxt);
var days = parseDays(daysTxt);
if (hours === null) throw new Error(T.badPeakHours);
if (days === null) throw new Error(T.badPeakDays);
out.peak = { multiplier: mult, weekdays: days, hours: hours };
}
if (mode === "token") {
var models = {};
var bad = null;
tr.querySelectorAll(".m-rows .row").forEach(function (r) {
if (bad) return;
var n = r.querySelector(".m-name").value.trim();
if (!n) return;
var pv = r.querySelector(".m-p").value.trim();
var cv = r.querySelector(".m-c").value.trim();
// Strings, not floats: the price must round-trip through YAML and the
// config file unchanged. 0.15 stored as a float can serialize as
// 0.15000000000000002 and the operator would not recognize their own
// price on the next visit.
if (!pv || !cv || isNaN(parseFloat(pv)) || isNaN(parseFloat(cv))) {
bad = n;
return;
}
models[n] = { prompt: pv, completion: cv };
});
if (bad) throw new Error(T.needPrices + ": " + bad);
if (Object.keys(models).length) out.models = models;
}
return out;
}
function collectProfile(pEl, T) {
var rules = [];
pEl.querySelectorAll("tr.br-rule").forEach(function (tr) {
rules.push(collectRule(tr, T));
});
return rules;
}
// ---- YAML export ---------------------------------------------------------
//
// Hand-rolled because the export target is a config.yaml the operator will
// paste into (or diff against) the live file. Only the shapes this page owns
// are emitted: maps, string/number scalars, and arrays of maps.
function yamlValue(v, indent) {
var pad = new Array(indent + 1).join(" ");
if (Array.isArray(v)) {
if (!v.length) return "";
var out = [pad + " []"];
var body = [];
v.forEach(function (item) {
if (item && typeof item === "object") {
var keys = Object.keys(item);
keys.forEach(function (k, idx) {
var val = item[k];
var prefix = idx === 0 ? pad + " - " : pad + " ";
if (val && typeof val === "object") {
body.push(prefix + k + ":");
body.push(yamlValue(val, indent + 3));
} else {
body.push(prefix + k + ": " + scalar(val));
}
});
} else {
body.push(pad + " - " + scalar(item));
}
});
return body.filter(Boolean).join("\n");
}
if (v && typeof v === "object") {
var keys = Object.keys(v);
if (!keys.length) return pad + " {}";
var res = [];
keys.forEach(function (k) {
var val = v[k];
if (val === undefined || val === null) return;
if (val && typeof val === "object") {
res.push(pad + k + ":");
res.push(yamlValue(val, indent + 1));
} else {
res.push(pad + k + ": " + scalar(val));
}
});
return res.filter(Boolean).join("\n");
}
return pad + scalar(v);
}
function scalar(v) {
if (typeof v === "number") return String(v);
if (typeof v === "boolean") return v ? "true" : "false";
// Quote every string: prices are strings by contract, but quoting the
// numeric-looking ones too means a hand-edited file can never turn "0.15"
// into 0.15 and lose the exact representation on the next round-trip.
return JSON.stringify(String(v));
}
function exportText() {
var dsl = { profiles: [] };
var body = document.getElementById("br-body");
var T = L();
body.querySelectorAll("[data-profile]").forEach(function (pEl) {
var id = pEl.dataset.profile;
var rules = collectProfile(pEl, T);
dsl.profiles.push({ id: id, rules: rules });
});
dsl.active = DATA && DATA.active ? DATA.active : (dsl.profiles[0] || {}).id;
return "billing:\n" + yamlValue(dsl, 1) + "\n";
}
// ---- render --------------------------------------------------------------
function say(msg, err) {
var el = document.getElementById("br-msg");
if (!el) return;
el.textContent = msg || "";
el.style.color = err ? "#e07a7a" : "rgba(200,180,220,.75)";
}
function render() {
var T = L();
var body = document.getElementById("br-body");
if (!body) return;
var dsl = (DATA && DATA.billing) || null;
var profiles = (dsl && dsl.profiles) || [];
var active = (DATA && DATA.active) || (profiles[0] && profiles[0].id) || "";
var known = (DATA && DATA.urls) || [];
var warn = "";
if (DATA && DATA.warnings && DATA.warnings.length) {
warn = "<div style='color:#e0a33e;font-size:12px;margin:0 0 10px'>&#9888; " +
esc(DATA.warnings.join(" &middot; ")) + "</div>";
}
var dl = known.map(function (u) { return "<option value='" + esc(u) + "'>"; }).join("");
if (!profiles.length) {
body.innerHTML = warn + "<div class='muted' style='padding:10px 0'>" + esc(T.noProfile) + "</div>" +
"<p><button class='ghost' data-act='newprofile'>+ " + esc(T.newProfile) + "</button></p>";
wire();
return;
}
var html = warn + "<datalist id='br-urls'>" + dl + "</datalist>";
profiles.forEach(function (p) {
var rules = p.rules || [];
html += "<div data-profile='" + esc(p.id) + "' style='margin:0 0 18px'>" +
"<div class='row' style='align-items:center;gap:8px;margin:0 0 6px'>" +
"<strong style='font-size:14px'>" + esc(p.id) + "</strong>" +
(p.id === active ? "<span class='okc' style='font-size:11px'>" + esc(T.active) + "</span>"
: "<button class='ghost small p-active' data-pid='" + esc(p.id) + "'>" + esc(T.setActive) + "</button>") +
"<span class='grow'></span>" +
"<button class='ghost small p-add' data-pid='" + esc(p.id) + "'>" + esc(T.addRule) + "</button>" +
"</div>" +
// Widths live on <th> AND the layout is fixed — the two go together.
//
// Measured: with table-layout:fixed and widths on the <td>s, the 954px
// container was divided into 9 equal 106px columns and every per-column
// width was discarded, so two adjacent headers sat on top of each other
// and the URL input was squeezed to "https:". With auto layout the
// browser ignores the <th> widths entirely and sizes from content, so
// the 70px currency column still collapsed to 48px (24px of input —
// too narrow to show "USD"). Under fixed layout the FIRST ROW's widths
// win, so the <th> widths below are the ones that take effect, and the
// total (1046px) exceeds the container on purpose: the wrapper scrolls.
"<div style='overflow-x:auto'><table style='width:100%;border-collapse:collapse;font-size:12px;table-layout:fixed'>" +
// colgroup, not th widths: under table-layout:fixed the first row's
// widths win, and the model-price cell holds a variable-width list of
// price rows whose content width varies per rule. Measuring that cell
// gave 352px on one rule and 206px on another — the whole row's
// layout then depended on WHICH RULE happened to be widest. A colgroup
// fixes the nine columns once and keeps every row identical.
"<colgroup>" +
// The last column holds a 「删除 / Delete」 button: 58px wrapped it
// to two lines and the button grew taller than the row. 74px fits it
// on one line; the peak-multiplier column gives up the difference
// (it only ever holds a single digit such as "2").
[214, 80, 62, 70, 66, 74, 62, 262, 74]
.map(function (w) { return "<col style='width:" + w + "px'>"; }).join("") +
"</colgroup>" +
"<tr style='text-align:left;opacity:.65'>" +
[[T.url], [T.mode], [T.currency], [T.monthly], [T.peakMult],
[T.peakHours], [T.peakDays], [T.models], [""]]
.map(function (c) {
// nowrap + ellipsis: the bilingual headers ("峰段星期 (UTC 1=周一)")
// are far wider than their column, and without this they spilled
// into the neighbouring header. The full text stays in title.
return "<th style='white-space:nowrap;overflow:hidden;text-overflow:ellipsis' title='" +
esc(c[0]) + "'>" + esc(c[0]) + "</th>";
}).join("") +
"</tr>" +
(rules.length
? rules.map(function (r) { return ruleRow(r, known, T); }).join("")
: "<tr><td colspan='9' class='muted' style='padding:8px 0'>" + esc(T.noRules) + "</td></tr>") +
"</table></div></div>";
});
html += "<p style='margin:6px 0 0'>" +
"<button class='ghost' data-act='newprofile'>+ " + esc(T.newProfile) + "</button> " +
"<button class='ghost' data-act='export'>" + esc(T.exportYaml) + "</button> " +
"<button data-act='save'>" + esc(T.save) + "</button></p>";
body.innerHTML = html;
wire();
}
function wire() {
var body = document.getElementById("br-body");
if (!body) return;
body.querySelectorAll(".m-add").forEach(function (b) {
b.onclick = function () {
var box = this.closest("td").querySelector(".m-rows");
var T = L();
var row = document.createElement("div");
row.className = "row";
row.style.cssText = "gap:6px;margin:3px 0";
row.innerHTML =
"<input class='m-name' placeholder='model' style='flex:1;min-width:80px'>" +
"<input class='m-p' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
"<input class='m-c' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
"<button class='ghost small m-del'>&times;</button>";
box.appendChild(row);
};
});
body.querySelectorAll(".m-del").forEach(function (b) {
b.onclick = function () { this.closest(".row").remove(); };
});
body.querySelectorAll(".r-del").forEach(function (b) {
b.onclick = function () {
var tr = this.closest("tr");
var pEl = tr.closest("[data-profile]");
var dsl = collectAll(pEl);
var idx = -1;
for (var i = 0; i < dsl.profiles.length; i++) {
if (dsl.profiles[i].id === pEl.dataset.profile) idx = i;
}
if (idx < 0) return;
dsl.profiles[idx].rules = dsl.profiles[idx].rules.filter(function (r) {
return r.url !== tr.dataset.url;
});
save(dsl);
};
});
body.querySelectorAll(".p-add").forEach(function (b) {
b.onclick = function () {
var pEl = b.closest("[data-profile]");
var dsl = collectAll(null);
var idx = -1;
for (var i = 0; i < dsl.profiles.length; i++) {
if (dsl.profiles[i].id === b.dataset.pid) idx = i;
}
if (idx < 0) return;
// Seed with the first configured URL: an empty url row would fail
// validation on save and the operator would not know why.
var first = ((DATA && DATA.urls) || [])[0] || "";
dsl.profiles[idx].rules.push({ url: first, mode: "free" });
save(dsl);
};
});
body.querySelectorAll(".p-active").forEach(function (b) {
b.onclick = function () {
var dsl = collectAll(null);
dsl.active = b.dataset.pid;
save(dsl);
};
});
var np = body.querySelector("[data-act='newprofile']");
if (np) np.onclick = newProfile;
var ex = body.querySelector("[data-act='export']");
if (ex) ex.onclick = doExport;
var sv = body.querySelector("[data-act='save']");
if (sv) sv.onclick = function () { save(collectAll(null)); };
}
function collectAll() {
var T = L();
var body = document.getElementById("br-body");
var dsl = { profiles: [] };
body.querySelectorAll("[data-profile]").forEach(function (pEl) {
dsl.profiles.push({ id: pEl.dataset.profile, rules: collectProfile(pEl, T) });
});
dsl.active = (DATA && DATA.active) || (dsl.profiles[0] || {}).id || "";
return dsl;
}
function save(dsl) {
var T = L();
say(T.saving);
call("PUT", { billing: dsl })
.then(function (j) {
DATA.billing = j.billing;
DATA.active = j.active;
DATA.warnings = j.warnings || [];
say(T.saved);
render();
// The billing page's totals come from these prices; refresh it so the
// operator sees the effect of the edit without hunting for the tab.
if (window.__billingRefresh) window.__billingRefresh();
})
.catch(function (e) { say(T.rejected + ": " + e.message, true); });
}
function newProfile() {
var dsl = collectAll();
var n = dsl.profiles.length + 1;
var id = "p" + n;
while (dsl.profiles.some(function (p) { return p.id === id; })) {
n++;
id = "p" + n;
}
dsl.profiles.push({ id: id, rules: [] });
if (!dsl.active) dsl.active = id;
save(dsl);
}
function doExport() {
var text = exportText();
var box = document.createElement("pre");
box.className = "configbox";
box.style.cssText = "margin-top:10px;max-height:46vh;overflow:auto;white-space:pre";
box.textContent = text;
var holder = document.getElementById("br-body");
var old = document.getElementById("br-export");
if (old) old.remove();
box.id = "br-export";
holder.appendChild(box);
var T = L();
var p = document.createElement("p");
p.style.margin = "6px 0 0";
p.innerHTML = "<button class='ghost small' id='br-dl'>" + esc(T.exportYaml) + " (.yaml)</button>";
holder.appendChild(p);
document.getElementById("br-dl").onclick = function () {
var blob = new Blob([text], { type: "text/yaml" });
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "billing-rules.yaml";
a.click();
setTimeout(function () { URL.revokeObjectURL(a.href); }, 2000);
};
}
function load() {
call("GET")
.then(function (j) {
DATA = { billing: j.billing || null, active: j.active, urls: j.urls || [], warnings: j.warnings || [] };
render();
})
.catch(function (e) {
var body = document.getElementById("br-body");
if (body) body.innerHTML = "<div class='muted'>" + esc(e.message) + "</div>";
});
}
// 导出给同页的视图切换:切到 rules 视图时按需拉取(进入页面已经 load 过一次,
// 再次进入不必重拉,除非这期间别人改过价格)。
window.__billingRulesLoad = load;
render();
load();
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(load);
})();</script>
]==],
},
-- Two elements on the EXISTING status page: a headline tile and a
-- per-source cost breakdown, so the number is visible without opening the
-- Billing tab.
elements = {
{
target = "status",
anchor = "top",
order = 5,
mount = [==[
<div class="card" id="billing-status-tile" style="padding:12px;margin-bottom:12px">
<div style="font-size:11px;opacity:.65" id="billing-tile-label"></div>
<div id="billing-status-total" style="font-size:22px;font-weight:600;margin-top:4px">—</div>
<div id="billing-status-sub" style="font-size:12px;opacity:.65;margin-top:2px"></div>
</div>
<script>
(function () {
function fmt(n) {
n = Number(n || 0);
if (n === 0) return "0";
if (Math.abs(n) < 0.000001) return n.toExponential(2);
return n.toFixed(Math.abs(n) < 1 ? 6 : 4);
}
async function tick() {
try {
var r = await fetch("/api/plugins/billing/state", { credentials: "same-origin" });
if (!r.ok) return;
var j = await r.json();
var st = j.state;
if (!st || !st.total) return;
var cur = st.currency || "USD";
// The await above yields, so the host page may have rebuilt or torn down
// this element in the meantime — and it does: renderStatus assigns
// pane.innerHTML wholesale on every refresh. Assigning to a null element
// threw a TypeError that the surrounding catch logged on every repaint.
// Re-check after every await rather than assuming the DOM survived it.
var totalEl = document.getElementById("billing-status-total");
if (!totalEl) return;
totalEl.textContent = cur + " " + fmt(st.total.cost);
// The tile's label is plugin UI text, so it follows the host language via
// the same pluginAPI surface the Billing page uses.
var lab = document.getElementById("billing-tile-label");
if (lab) {
var lang = (window.pluginAPI && pluginAPI.lang) || "zh";
lab.textContent = lang === "zh" ? "总开销(billing 插件)" : "Total spend (billing plugin)";
}
var parts = [];
var srcs = st.by_source || {};
var names = Object.keys(srcs).sort(function (a, b) {
return (srcs[b].cost || 0) - (srcs[a].cost || 0);
});
for (var i = 0; i < Math.min(3, names.length); i++) {
parts.push(names[i] + " " + fmt(srcs[names[i]].cost));
}
var sub2 = document.getElementById("billing-status-sub");
if (sub2) sub2.textContent =
(st.total.requests || 0) + " requests" + (parts.length ? " · top: " + parts.join(" · ") : "");
} catch (e) {
// Same reasoning as the Billing page: decoration must never break the
// host page, but a silent catch turns a broken widget into "the plugin
// just doesn't show anything" with no way to tell why.
if (window.console && console.error) console.error("[billing] status tile refresh failed", e);
}
}
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(tick);
tick();
})();
</script>
]==],
},
},
}
return plugin