Files
ModelRouter/internal/lua/billing_ui_test.go
root 7941b4110a refactor(billing)!: 计费全部沉入插件,内核零 billing 代码;规则由插件自持
按维护者要求(issue #1 回复方向):内核不处理任何 billing 特有逻辑;插件有完整
OS 权限,自己保存配置文件。据此不再保留任何"内核 ↔ billing 插件"的专用对接。

## 内核(删除)
- `internal/billing`(Go 编译器 + 测试):删除;
- `internal/core` 的 billing 接口/降级应用/规则加载:删除(含 `billingPath`);
- `internal/gateway` 的 `/api/plugins/billing/rules` 端点与测试:删除;
- `config` 的 `BillingDSL/Profile/Rule/Token/Peak/LongContext` 与校验、
  `billing:`/`billing_file:` 配置段:删除。系统配置里不再有 billing 段。
内核只保留所有插件通用的能力:加载 .lua、插件状态持久化、插件目录
(`__llmsproxy_plugin_dir`)、`plugin.http` 路由、插件可用的 io/os。

## 插件(billing.lua)
- 规则文件移到**插件自己的目录**:`<plugin_dir>/billing.json`,由插件用 io 自己
  读写(tmp + rename 原子替换);加载插件时自己读文件并编译;
- 规则以**源名** `source` 为键(`url:"*"` 仅表示全局模型价目表):URL→源名映射
  属于内核配置,插件不为它和内核对接;编辑页在浏览器里取 `/api/sources` 选源名;
- 规则编辑 API 改为插件自己的 `/api/plugins/http/billing/rules`
  (GET/PUT/POST/DELETE,roles=admin),写盘也在插件内完成;
- 计费账目仍在插件状态文件(内核通用持久化)`<plugin_dir>/.billing.state.json`;
- `priceFor` 落地 profile 级 `default` 策略与显式 `mode=unpriced` 的优先级。

## 迁移
生产已迁移:`config.yaml` 的 `billing:` 规则转为 `plugins/billing.json`(按源名
重写),并从系统配置中移除;规则文件与系统配置从此各自独立保存。

## 判据
- `internal/lua/billing_rules_test.go`:插件自写 billing.json 并重启恢复、
  free/subscription/token/通配符编译语义、1e6+1e6 token = 0.75 端到端、
  非法规则被拒且不覆盖旧文件、reasoning/long_context/peak;
- `internal/lua/billing_test.go`:profile default 策略三态 + 显式 unpriced 优先;
- `internal/lua/billing_ui_test.go`:规则编辑页仍挂在 billing 页内、列宽/列数守卫。
2026-10-20 18:57:23 +08:00

500 lines
21 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

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

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/http/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)
}
}