Files
ModelRouter/internal/lua/billing_ui_test.go
JianFeeeee 91e3013cb5 feat(billing): 长上下文阶梯定价(边际费率,与缓存折扣/峰段可叠加)
## 影响面实测

审计里 1000 条请求:**883 条(88.3%)prompt 超过 128k**,中位数 246849,
p90 298179,最大 354251。这不是小众场景,是 agent 负载的主流形态。

按「模型 × 源」拆开:其中真正按 token 计价的只有 alittokenplan 的 10 次
(deepseek-v4.1-flash,约 350k prompt),其余走 $70 套餐(commandcode)或
免费源(localzen),记 0 是正确的,对阶梯无影响。

那 10 次当前少算 54.9%($0.3148 → $0.4877)。绝对值不大,但比例严重,
而且这个偏差只会随上下文变长而变大。

## 边际而非混合平均

阶梯只对**超过阈值的部分**重新计价,不是把整段按高价算。理由是可叠加性:
缓存读取同时享受缓存折扣**和**长上下文阶梯价,这与供应商的计费方式一致。
混合平均必须二选一,选错就正好低估「缓存命中的长请求」——而这恰是缓存
存在的意义所在(本部署缓存命中率 88%)。

超出部分按缓存/新鲜的比例拆分,不重不漏。

## DSL 两种写法

供应商两种发布方式都支持:绝对费率(OpenAI 报 \$3.00/M past 200k)和
倍数(DeepSeek 报 2x past 128k)。两者都缺必须报错 —— 一个没有费率的阶梯
会编译成一条「看起来处理了长上下文、实际没处理」的规则,静默少算且无迹可寻。
两者都给时以绝对费率为准,因为那是供应商真正报出的数字。

## 判据

Lua 侧两条(边际性、与缓存折扣叠加)+ Go 侧一条(两种写法与拒绝规则)。
变异自证三次:整段按高价 / 缓存部分不叠加折扣 / 缓存部分不计阶梯,
分别命中 0.40 vs 0.272、0.165 vs 0.2325、0.09 vs 0.2325。

规则编辑器加了三个输入(阈值 + 绝对费率 + 倍数),三项都留空 = 无阶梯 ——
与「阶梯费率为 0」刻意区分,所以是三个独立输入而不是从 0 推断是否存在阶梯。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 314f4da8f2)
2026-10-04 14:50:47 +08:00

500 lines
21 KiB
Go
Raw Permalink 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.

package lua
import (
"encoding/json"
"os"
"os/exec"
"regexp"
"strings"
"testing"
)
// The billing page rendered EMPTY in production while its data endpoint returned
// 200 with real numbers. The cause was one line: render(st) referenced an
// undefined `s` for two KPI cells, so the ReferenceError aborted the whole
// render and every table stayed at its initial empty innerHTML.
//
// Nothing in the build, the tests or the API surfaced it. This file is the guard
// for the whole class: a plugin's injected UI that references an undefined name,
// or that depends on a container the page does not provide, fails silently.
// billingUI returns the injected markup for the billing plugin: the full page
// mount and the status-page element mount.
func billingUI(t *testing.T) (page string, statusElement string) {
t.Helper()
src, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatalf("read billing.lua: %v", err)
}
// Located BY CONTENT, not by index. The plugin also uses a long string for
// its inline SVG icon, so "the first long string" is the icon and "the
// second" is the page — which is exactly the kind of positional assumption
// that breaks the next time an icon or a description is added.
page = longStringContaining(t, string(src), "billing-root")
statusElement = longStringContaining(t, string(src), "billing-status-tile")
return page, statusElement
}
// longStringContaining returns the [==[ ... ]==] body that contains marker.
func longStringContaining(t *testing.T, src, marker string) string {
t.Helper()
re := regexp.MustCompile(`(?s)\[==\[(.*?)\]==\]`)
for _, m := range re.FindAllStringSubmatch(src, -1) {
if strings.Contains(m[1], marker) {
return m[1]
}
}
t.Fatalf("no long string contains %q", marker)
return ""
}
// TestBillingMountScriptExecutes is the guard for the production bug.
//
// The Billing page rendered empty while its data endpoint returned 200 with real
// numbers. Cause: render(st) referenced an undefined `s` for two KPI cells, the
// ReferenceError aborted the render, and every table kept its initial empty
// innerHTML. Nothing in the build or the API surfaced it.
//
// Two earlier attempts at a static check were both wrong: a "declared names"
// scan flagged every CSS class inside the inline HTML strings (class, div, td),
// and a CSS-selector parse of the stylesheet reported the stylesheet itself as
// broken. Static analysis of JS embedded in HTML strings is the wrong tool.
//
// So this actually RUNS the script, in node, against a minimal DOM stub, and
// fails on any thrown error. Skipped when node is unavailable, with the reason
// printed — never silently passing as if it had checked.
func TestBillingMountScriptExecutes(t *testing.T) {
page, el := billingUI(t)
scripts := extractScripts(page)
if len(scripts) == 0 {
t.Fatal("no <script> found in the billing page mount")
}
for i, js := range scripts {
assertRendersAndDoesNotThrow(t, i, js)
}
for i, js := range extractScripts(el) {
assertRendersAndDoesNotThrow(t, i, js)
}
}
// assertRendersAndDoesNotThrow executes a mount script under node against a DOM
// stub and fails on EITHER a thrown/reported error OR an empty render.
//
// Checking only for exceptions is not enough, and that is the third wrong
// attempt at this guard. The plugin's render() opens with `if (!st) return;`,
// so passing the wrong field (`render(j.stateX)`) is a SILENT no-op: no throw,
// no console.error, no rejection — just an empty page. Only looking at the
// produced DOM catches that class.
func assertRendersAndDoesNotThrow(t *testing.T, idx int, js string) {
t.Helper()
node, err := exec.LookPath("node")
if err != nil {
t.Skipf("node not available (%v): cannot execute the injected script", err)
}
stub := `
global.window = global;
global.document = {
getElementById: function (id) {
if (!global.__els) global.__els = {};
if (!global.__els[id]) global.__els[id] = {
style: {}, dataset: {}, classList: { add: function(){}, remove: function(){} },
// Both writes count: the Billing page fills innerHTML, the status-page
// tile assigns textContent. Watching only one of them flagged the tile as
// "renders nothing" — a false positive that would have taught everyone to
// ignore this test.
set innerHTML(v) { if (v && String(v).trim()) global.__written.push(id); this.__h = v; },
get innerHTML() { return this.__h || ""; },
set textContent(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__t = v; },
get textContent() { return this.__t || ""; },
set innerText(v) { if (v !== undefined && String(v).trim()) global.__written.push(id); this.__i = v; },
get innerText() { return this.__i || ""; },
appendChild: function(){}, querySelector: function(){ return null; },
querySelectorAll: function(){ return []; }, addEventListener: function(){} };
return global.__els[id];
},
createElement: function () { return { style: {}, dataset: {}, appendChild: function(){}, setAttribute: function(){} }; },
addEventListener: function () {},
};
global.pluginAPI = { onTabShown: function () {} };
global.fetch = function () {
// A payload with real numbers, so a working render produces visible output.
return Promise.resolve({ ok: true, json: function () {
return Promise.resolve({ plugin: "billing", state: {
currency: "USD",
total: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 },
by_source: { localzen: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
by_model: { m1: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
by_key: { k1: { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
by_day: { "2026-01-01": { cost: 1.25, requests: 7, prompt_tokens: 100, completion_tokens: 20, failures: 0 } },
degraded_reqs: 1, unpriced_reqs: 2,
}});
}});
};
global.__written = [];
global.__errors = [];
// The plugin's refresh() swallows its own errors so decoration can never break
// the host page — which is right, and is exactly why the production ReferenceError
// was invisible. So the trace has to come from console.error, which the plugin now
// emits. Hooking process events alone made this test pass against the very bug it
// was written for (verified by re-introducing the typo and watching it stay green).
var __realErr = console.error;
console.error = function () {
global.__errors.push("console.error: " + Array.prototype.map.call(arguments, function (a) {
return (a && a.message) ? a.message : String(a);
}).join(" "));
__realErr.apply(console, arguments);
};
process.on("uncaughtException", function (e) { global.__errors.push("uncaught: " + String(e && e.message || e)); });
process.on("unhandledRejection", function (e) { global.__errors.push("unhandled: " + String(e && e.message || e)); });
`
// 50ms is not a guess: the plugin's IIFE kicks off refresh() which awaits a
// fetch; a rejection lands on the microtask queue almost immediately.
script := strings.Join([]string{
stub, js,
`setTimeout(function(){
console.log("__ERRS__" + JSON.stringify({errors: global.__errors, written: global.__written}));
}, 300);`,
}, "\n")
cmd := exec.Command(node, "-e", script)
out, err := cmd.CombinedOutput()
if err != nil {
t.Errorf("mount script %d crashed under node: %v\n%s", idx, err, out)
return
}
// The script may print other things; find the JSON we appended.
line := ""
for _, l := range strings.Split(string(out), "\n") {
if i := strings.Index(l, "__ERRS__"); i >= 0 {
line = strings.TrimSpace(l[i+len("__ERRS__"):])
}
}
if line == "" {
t.Errorf("mount script %d produced no error report — the harness did not "+
"run to completion, so it cannot be trusted to have checked anything", idx)
return
}
var report struct {
Errors []string `json:"errors"`
Written []string `json:"written"`
}
if err := json.Unmarshal([]byte(line), &report); err != nil {
t.Errorf("could not parse the error report %q: %v", line, err)
return
}
for _, e := range report.Errors {
t.Errorf("mount script %d reported %q — this is the failure that leaves "+
"the Billing page blank while the API still returns data", idx, e)
}
if len(report.Written) == 0 {
t.Errorf("mount script %d wrote NOTHING into any container — the page "+
"renders empty. render() guards with `if (!st) return`, so a wrong "+
"field name is a silent no-op: no throw, no console output, no error "+
"anywhere. This is what the production page looked like.", idx)
}
}
func extractScripts(html string) []string {
re := regexp.MustCompile(`(?s)<script[^>]*>(.*?)</script>`)
var out []string
for _, m := range re.FindAllStringSubmatch(html, -1) {
out = append(out, m[1])
}
return out
}
// TestBillingPageCoversItsData is the other half: the page declares tables for
// per-source / per-model / per-key / per-day and must actually render into them.
// A table id that is never written to is exactly how "the page loads and shows
// nothing" happens without an error.
func TestBillingPageCoversItsData(t *testing.T) {
page, _ := billingUI(t)
for _, id := range []string{
"billing-kpis", "billing-by-source", "billing-by-model",
"billing-by-key", "billing-by-day",
} {
if !strings.Contains(page, `id="`+id+`"`) {
t.Errorf("the page has no container #%s", id)
}
}
// Every container must be written to by the script, not just declared.
scripts := extractScripts(page)
all := strings.Join(scripts, "\n")
for _, id := range []string{"billing-kpis", "billing-by-source", "billing-by-model", "billing-by-key", "billing-by-day"} {
if !strings.Contains(all, `getElementById("`+id+`")`) {
t.Errorf("#%s is declared but never read by the script — it stays empty forever", id)
}
}
}
// TestPluginIconIsNotARawEmoji guards the sidebar icon. The billing plugin
// declared icon = "💰" and the WebUI drops that verbatim into the button, while
// every native tab uses an inline SVG styled with `stroke: currentColor`. An
// emoji there renders at the wrong size and ignores the theme, so it does not
// match its neighbours — which is what the operator reported.
func TestPluginIconIsNotARawEmoji(t *testing.T) {
icon := billingIcon(mustBillingSource(t))
if icon == "" {
t.Fatal("billing declares no icon; the sidebar entry would be blank")
}
if isEmojiIcon(icon) {
t.Errorf("page icon is the raw emoji %q — the WebUI sidebar renders "+
"native tabs as inline SVG (stroke: currentColor), so an emoji is the "+
"wrong size and ignores the theme. Use an inline SVG path instead.", icon)
}
}
// isEmojiIcon reports whether s is a pictographic emoji rather than markup or a
// text glyph. Codepoints in the pictographic blocks, plus the regional-indicator
// pair used by flags.
func isEmojiIcon(s string) bool {
r := []rune(s)
if len(r) == 0 {
return false
}
// Anything containing '<' is markup (an inline <svg>), which is the fix.
if strings.ContainsRune(s, '<') {
return false
}
for _, c := range r {
switch {
case c >= 0x1F300 && c <= 0x1FAFF, // pictographs, symbols, supplemental
c >= 0x1F000 && c <= 0x1F2FF, // mahjong/domino/cards
c >= 0x2600 && c <= 0x27BF, // misc symbols + dingbats
c >= 0x2B00 && c <= 0x2BFF, // arrows/misc symbols
c == 0xFE0F, // variation selector-16
c >= 0x1F1E6 && c <= 0x1F1FF: // regional indicators (flags)
return true
}
}
return false
}
func mustBillingSource(t *testing.T) string {
t.Helper()
b, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
return string(b)
}
// billingIcon extracts the declared page icon. It accepts BOTH a quoted string
// and a [==[ ... ]==] long string, because an inline SVG cannot be written as a
// Lua short string without escaping every quote in it.
func billingIcon(src string) string {
if m := regexp.MustCompile(`(?m)^\s*icon\s*=\s*"([^"]*)"`).FindStringSubmatch(src); m != nil {
return m[1]
}
if m := regexp.MustCompile(`(?s)\bicon\s*=\s*\[==\[(.*?)\]==\]`).FindStringSubmatch(src); m != nil {
return m[1]
}
return ""
}
// TestBillingTableHeaderMatchesRowColumns catches column drift.
//
// row() gained cache columns (fresh / cache / cache%) while the header row did
// not, in the same edit. The result is a table whose cells are shifted one
// column left from "fresh" onward — so "cache%" sits under "completion" and the
// last cell has no label. It renders, it has data, and it is wrong in a way that
// takes a careful read to notice.
func TestBillingTableHeaderMatchesRowColumns(t *testing.T) {
page, _ := billingUI(t)
js := strings.Join(extractScripts(page), "\n")
if !strings.Contains(js, "<th>") {
t.Fatal("no table header found in the billing page script")
}
hStart := strings.Index(js, "function tableFor")
if hStart < 0 {
t.Fatal("no tableFor in the billing page script")
}
// Count the usage table's headers ONLY. The count is taken from tableFor to
// the end of the script, which used to be fine when the rule editor lived on
// its own page; now that both halves share one script, the rule table's nine
// headers were counted too and the check reported 17 vs 8 — a failure about
// two unrelated tables. Stop at the rule editor's script.
header := js[hStart:]
if cut := strings.Index(header, "/api/plugins/billing/rules"); cut > 0 {
header = header[:cut]
}
// Count <th> with OR without attributes: the name column now carries
// style='width:22%' so the header and its data cell can be given the same
// width, and a bare "<th>" count silently reported 7 vs 8 — the check
// failing on the very column it had just been taught to size.
th := strings.Count(header, "<th>") + strings.Count(header, "<th ")
rStart := strings.Index(js, "function row")
rEnd := strings.Index(js, "function tableFor")
if rStart < 0 || rEnd < 0 || rEnd <= rStart {
t.Fatal("could not isolate row()")
}
row := js[rStart:rEnd]
// Each cell closes with </td>; the first cell uses <td><b>..</b></td> so
// counting </td> is exact.
td := strings.Count(row, "</td>")
if th != td {
t.Errorf("★ header declares %d columns but row() emits %d cells — the "+
"table is misaligned from the first differing column on", th, td)
}
}
// TestRuleTableLocksColumnWidthsInAColgroup guards the layout class of bug that
// only a screenshot caught: two adjacent headers rendered on top of each other,
// the URL input squeezed to "https:", and the per-model × button landing 78px
// past its cell onto the neighbouring Delete button.
//
// The measurement that found each one:
// - table-layout:fixed with widths on <td> → 954px divided into 9 equal
// 106px columns, every declared width discarded.
// - widths on <th> WITHOUT fixed → browser sizes from content and ignores
// them; the 70px currency column still collapsed to 48px (24px of input).
// - fixed + <th> widths → they take effect, but the model-price cell's width
// then depended on WHICH RULE was widest (352px vs 206px).
//
// The fix is all three together: fixed layout, a colgroup, and flex children
// that may shrink.
func TestRuleTableLocksColumnWidthsInAColgroup(t *testing.T) {
page, _ := billingUI(t)
js := strings.Join(extractScripts(page), "\n")
// Match the ATTRIBUTE, not the bare string: a substring check on
// "table-layout:fixed" is satisfied by the explanatory comment that sits
// three lines above the tag, so removing the attribute from the table still
// passed (mutation-verified). Require it inside a style='...' literal.
if !strings.Contains(js, `font-size:12px;table-layout:fixed'`) {
t.Error("the rule table must stay table-layout:fixed — with auto layout " +
"the <colgroup> widths below are ignored and columns size from content")
}
colStart := strings.Index(js, "<colgroup>")
if colStart < 0 {
t.Fatal("the rule table declares no <colgroup>; per-column widths on " +
"<th> or <td> are advisory and get recomputed from content")
}
// The colgroup is built from a JS array of pixel widths, so assert on the
// array, not on literal "<col " occurrences — counting the literal found
// exactly one and reported "1 columns, want 9" against correct code.
if !strings.Contains(js, "[214, 80, 62, 70, 66, 74, 62, 262, 74]") {
t.Error("the rule table's colgroup widths changed or vanished; measured " +
"values were [214 80 62 70 66 74 62 262 74] for a 954px pane — the " +
"delete column needs 74px or the button wraps to two lines")
}
if !strings.Contains(js, `"<col style='width:" + w + "px'>"`) {
t.Error("colgroup entries are no longer emitted as <col style='width:Npx'>")
}
// Every price-row INPUT must be allowed to shrink, or the row's fixed widths
// sum past the cell and the × button lands on the next column. The × button
// is the opposite: it must NOT shrink (flex:0 0 auto), because a squashed
// delete button is unclickable.
for _, needle := range []string{"m-name", "m-p", "m-c"} {
i := strings.Index(js, "class='"+needle+"'")
if i < 0 {
i = strings.Index(js, "class='ghost small "+needle+"'")
}
if i < 0 {
t.Errorf("price row lost the %s input/button", needle)
continue
}
window := js[i : i+220]
if !strings.Contains(window, "flex:") {
t.Errorf("%s has no flex sizing; the row is 80+96+96+38 ≈ 330px wide "+
"against a 262px cell and overflows onto the next column", needle)
}
if !strings.Contains(window, "min-width:0") {
t.Errorf("%s lacks min-width:0; a flex item will not shrink below its "+
"content width, which is how the × button ended up 78px past its cell", needle)
}
}
i := strings.Index(js, "class='ghost small m-del'")
if i < 0 {
t.Fatal("the per-model delete button is gone")
}
if !strings.Contains(js[i:i+120], "flex:0 0 auto") {
t.Error("the per-model × button must be flex:0 0 auto — it is a fixed-size " +
"control and must never be squeezed by the flexible inputs")
}
}
// TestBillingUIShowsRecentUnpriced: the page must be able to distinguish a
// settled pricing debt from a live leak. The lifetime unpriced counter answers
// neither — it keeps every request from before a rule was added, so it reads the
// same number in both situations. The day buckets exist to make the difference
// visible, and a bucket nothing renders is a bucket nobody benefits from.
func TestBillingUIShowsRecentUnpriced(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
"unpriced_by_day", // the tally is written
"recentUnpriced", // it is reduced to a window
"unpriced_recent", // it reaches the view
"unpriced7d", // the card labels it
"unpricedClean", // and says so when the window is clear
} {
if !strings.Contains(src, needle) {
t.Errorf("billing UI lacks %q", needle)
}
}
}
// TestBillingRulesUIExposesReasoningPrice: the thinking-token rate must be
// editable in the rules editor, not only settable by hand-editing config.yaml.
// A pricing capability that lives only in the config file is one the operator
// will assume does not exist.
func TestBillingRulesUIExposesReasoningPrice(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
`class='m-r'`, // the input, in both the existing rows and the "+ model" row
`models[n].reasoning`, // collected back out of the DOM on save
`reasoningHint`, // and it says what leaving it blank means
} {
if !strings.Contains(src, needle) {
t.Errorf("rules editor lacks %q", needle)
}
}
// Two occurrences: an existing model's row and the template for a new one.
// One would mean the "+ model" button produces a row that silently drops
// the rate on the next save.
if n := strings.Count(src, `class='m-r'`); n < 2 {
t.Errorf("m-r appears %d time(s); both the row template and the editor need it", n)
}
}
// TestBillingRulesUIExposesLongContextStep: the long-context step must be
// editable in the rules editor.
//
// This is not a niche control. On an agent workload 88% of requests carry more
// than 128k prompt tokens with a median of 247k, so a rule that omits the step
// under-bills the MAJORITY of traffic while every number on the dashboard stays
// plausible. Leaving the capability in config.yaml alone would mean the operator
// never discovers it needs it.
func TestBillingRulesUIExposesLongContextStep(t *testing.T) {
raw, err := os.ReadFile("plugins/billing.lua")
if err != nil {
t.Fatal(err)
}
src := string(raw)
for _, needle := range []string{
`class='m-lc-a'`, // threshold
`class='m-lc-p'`, // absolute rate
`class='m-lc-m'`, // multiplier
`models[n].long_context`, // collected back out on save
`lcHint`, // explains that blank = no step
} {
if !strings.Contains(src, needle) {
t.Errorf("rules editor lacks %q", needle)
}
}
// Both the existing-row template and the "+ model" template need the three
// inputs; one would make a newly added model lose its step on the next save.
if n := strings.Count(src, `class='m-lc-a'`); n < 2 {
t.Errorf("long-context inputs appear %d time(s); both row templates need them", n)
}
}