Files
ModelRouter/internal/lua/plugins/billing.lua
JianFeeeee d9652f479a fix(plugins): 插件 Lua 报错不再拖垮网关(生产事故修复)
## 事故

13:37 部署后线上 6 次 SIGSEGV 崩溃循环,8081 完全不可用,用户报大量
connect error。崩溃点固定在 internal/lua/plugins.go:invoke → L.Call →
golua StackTrace 里的 lua_getinfo。

## 根因(不是并发/GC/锁)

golua 的 callEx 在**任何** pcall 失败后无条件执行 L.StackTrace(),而
StackTrace 调 lua_getinfo,这个 LuaJIT 构建在栈够深时(带 AUTO 链轨迹的
request_end payload 正好够深)直接段错误。这是 C 层信号,Go 无法 recover,
所以一个插件的脚本错误就能带走整个进程和所有在途请求。

触发错误来自我上一轮加的 billing 日级维度:

    add(bucket(bucket(bucket(s.by_day_src, dk), payload.source)), ...)

三个 bucket( 只对应两个 ),最外层 bucket() 只收到一个参数,k=nil,于是
billing.lua:141 `tbl[k] = b` 抛 "table index is nil",**每个请求都抛**。

同时还有第二个 bug:中间层用了 bucket()(返回 emptyBucket,含 cost/requests
字段)当作嵌套容器,结构也是错的。改为 dayMap() 返回纯表。

## 修法

1. billing.lua:修正括号,多层容器改用 dayMap()。
2. **pcall 守卫**(真正的架构修复):在 setupGlobals 里注册
   __llmsproxy_call_hook,钩子改为经它调用。

       function __llmsproxy_call_hook(fn, payload)
         local ok, res = pcall(fn, payload)
         if not ok then return nil, tostring(res) end
         return res, nil
       end

   Lua 侧 pcall 在 golua 看到非零 pcall 状态之前就拦下错误,C 栈回溯路径
   永远进不去。错误变成普通返回值 (nil, msg),Go 侧记进 hook_errors 并跳过
   ——"插件出错不影响请求转发"这条承诺对脚本错误也终于成立,而不只是对 Go panic。

## 这同时修掉了那个查了很久的间歇崩溃

同一个机制解释了此前 8/20 复现、却查不出根因的 SIGSEGV(怀疑过 janitor 竞态、
GC、LuaJIT 全局状态、VM 释放时序,全部排除)。实测对比:

  TestBillingPrecedence   修复前 8/20 崩溃 → 修复后 0/20
  并发建 16 个 VM 的探针   修复前 3/3  崩溃 → 修复后 0/6
  全量 ./...              连跑 5 次全绿

那些崩溃本来就是一个 Lua 钩子错误在栈深时炸掉 StackTrace,时机随机所以看着
像并发问题。

## 判据

TestHookThatRaisesDoesNotCrashTheProcess:装一个每请求必崩的插件,连打 50 次,
断言进程存活 + 错误被记录 + 同状态里健康的 billing 插件照常工作。
3 个变异(守卫不 pcall / 守卫名写错 / 守卫未注册)全部被捕获,其中第一个直接
让 SIGSEGV 重现,说明守卫就是唯一防线。

## 线上验证

往生产插件目录放一个每请求必然报错的插件,连打 30 个真实流式请求:

  30× HTTP 200,SIGSEGV 0 次
  hook_errors 记录 count=44 且指名 zbroken-test(可观测)
  billing 照常累计(2999 请求 / $0.5668)

测试插件已移除。

回滚点:/usr/local/bin/llmsproxy.bak-real-<TS>、billing.lua.bak-real-<TS>。
2026-10-02 14:07:44 +08:00

1436 lines
62 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">
<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>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,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>
<div class="card" style="padding:14px;margin-top:16px">
<h3 id="billing-h-day" style="margin:0 0 10px;font-size:14px"></h3>
<div id="billing-by-day"></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",
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: "输出",
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 悬停看全名);数字列 break-all 在列宽内换行而不是
// 把表格撑出卡片。单元格结构与列数不变,列数判据不受影响。
return "<tr><td style='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 style='word-break:break-all'>" + money(b.cost, cur) + "</td>" +
"<td style='word-break:break-all'>" + fmtInt(b.requests || 0) + "</td>" +
"<td style='word-break:break-all'>" + fmtInt(b.prompt_tokens || 0) + "</td>" +
"<td style='word-break:break-all'>" + fmtInt(fresh) + "</td>" +
"<td style='word-break:break-all'>" + fmtInt(b.cache_hit_tokens || 0) + "</td>" +
"<td style='word-break:break-all'>" + esc(cacheRate(b)) + "</td>" +
"<td style='word-break:break-all'>" + 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();
var h = "<table style='width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;word-break:break-word'>" +
"<tr style='text-align:left;opacity:.65'><th>" + TH.thName + "</th><th>" + TH.thCost +
"</th><th>" + TH.thReqs + "</th><th>" + TH.thPrompt +
"</th><th>" + TH.thFresh + "</th><th>" + TH.thCache + "</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>";
}
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();
};
// 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();
refresh();
});
}
}
})();
</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>
]==],
},
},
}
-- 第二页:计费规则编辑。
--
-- 与统计页分开,而不是塞进同一页的标签里:规则是可编辑的配置,统计是只读的
-- 结果,混在一个页面里会让"改数字"和"看数字"的边界变模糊。
-- pages is built by APPENDING. Writing plugin.ui.pages[2] instead makes the
-- table sparse (index 2 with no 1, 2), and Lua's tojson/JSON conversion then
-- emits an OBJECT {"2": {...}} instead of an array — which the Go side decodes
-- to nothing at all. The plugin then loaded with UI == nil and no error
-- anywhere: the totals page, the status tile and this editor all silently
-- vanished. First-wins on page_id is enforced Go-side, so appending is safe.
plugin.ui.pages = plugin.ui.pages or {}
table.insert(plugin.ui.pages, {
page_id = "billing-rules",
title = "Billing rules",
icon = [==[<svg viewBox="0 0 24 24"><path d="M4 7h10M18 7h2M4 12h4M12 12h8M4 17h8M16 17h4"/><path d="M14 5l2 2-2 2M10 10l-2 2 2 2M12 15l2 2-2 2"/></svg>]==],
order = 41,
mount = [==[
<div id="br-root" style="padding:16px;min-width:0;max-width:100%">
<div id="br-body"><div class="muted">…</div></div>
</div>
<script>
(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 || {});
var rows = keys.map(function (m) {
var t = models[m] || {};
return "<div class='row' style='gap:6px;margin:3px 0'>" +
"<input class='m-name' value='" + esc(m) + "' style='flex:1;min-width:80px'>" +
"<input class='m-p' value='" + esc(t.prompt == null ? "" : t.prompt) +
"' placeholder='" + esc(T.prompt) + "' style='width:96px'>" +
"<input class='m-c' value='" + esc(t.completion == null ? "" : t.completion) +
"' placeholder='" + esc(T.completion) + "' style='width:96px'>" +
"<button class='ghost small m-del' title='" + esc(T.delRule) + "'>&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;
return "<tr class='br-rule' data-url='" + esc(rule.url || "") + "'>" +
"<td><input class='r-url' value='" + esc(rule.url || "") + "' list='br-urls' style='width:100%'>" +
(unknown ? "<div style='color:#e0a33e;font-size:11px'>&#9888; " + esc(T.warnNoSource) + "</div>" : "") +
"</td>" +
"<td><select class='r-mode'>" + opts + "</select></td>" +
"<td style='width:74px'><input class='r-cur' value='" + esc(rule.currency || "") + "' placeholder='USD'></td>" +
"<td style='width:92px'><input class='r-sub' type='number' step='0.01' value='" +
(rule.subscription ? esc(rule.subscription) : "") + "' placeholder='" + esc(T.monthly) + "'></td>" +
"<td style='width:64px'><input class='r-mult' type='number' step='0.1' value='" +
(p && p.multiplier ? esc(p.multiplier) : "") + "' placeholder='" + esc(T.peakMult) + "'></td>" +
"<td style='width:104px'><input class='r-hours' value='" + esc(hoursText(p)) + "' placeholder='1-4,6-10'></td>" +
"<td style='width:74px'><input class='r-days' value='" + esc(daysText(p)) + "' placeholder='1,2,3'></td>" +
"<td style='min-width:210px'>" + modelBlock(rule.models, T) + "</td>" +
"<td><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>" +
"<div style='overflow-x:auto'><table style='width:100%;border-collapse:collapse;font-size:12px;min-width:900px'>" +
"<tr style='text-align:left;opacity:.65'>" +
"<th>" + esc(T.url) + "</th><th>" + esc(T.mode) + "</th><th>" + esc(T.currency) + "</th>" +
"<th>" + esc(T.monthly) + "</th><th>" + esc(T.peakMult) + "</th><th>" + esc(T.peakHours) + "</th>" +
"<th>" + esc(T.peakDays) + "</th><th>" + esc(T.models) + "</th><th></th></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>";
});
}
render();
load();
if (window.pluginAPI && pluginAPI.onTabShown) pluginAPI.onTabShown(load);
})();
</script>
]==],
})
return plugin