mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
## 事故
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>。
1436 lines
62 KiB
Lua
1436 lines
62 KiB
Lua
-- 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 { "&": "&", "<": "<", ">": ">", '"': """ }[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, "'")) + "'>" + 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 { "&": "&", "<": "<", ">": ">", '"': """ }[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) + "'>×</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'>⚠ " + 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'>⚠ " +
|
||
esc(DATA.warnings.join(" · ")) + "</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'>×</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 |