-- 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..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 { = { cost, requests, ...tokens } } -- by_model { = { cost, ... } } -- by_key { = { 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 ") 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 ]==], }, -- 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 = [==[
—
]==], }, }, } return plugin