mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
三个问题都来自生产实测,不是代码审阅。
## 1. 缓存命中被计费却不被统计
网关确实从上游 usage 提取了 prompt_cache_hit_tokens(审计里能看到
cache_hit_tokens: 270104 / cache_reported: true,占 prompt 的 99.9%),
costFor() 也用它给缓存段定价了 —— 但**没有任何 bucket 记录它**。
结果:一个 99.88% 命中率的网关,报表显示 prompt_tokens 却看不出其中
多少是缓存读,也无从按源/模型/key 看命中率。
每个 bucket 现在多三个字段:
cache_hit_tokens 命中数(按上游上报)
cache_fresh_tokens 未命中的 prompt
cache_reported_reqs 上游确实上报了缓存数的请求数
第三个字段是刻意的:**「零命中」与「上游根本不上报」在命中总量里完全一样**,
而它们在「缓存折扣有没有生效」这个问题上含义相反。没有它就无法区分,
只能猜。
chat.go 的 payload 之前**没有** cache_reported(审计有、插件没有),
所以任何插件侧的缓存统计都只能猜 —— 已补上。
旧 state 文件的 bucket 没有这些字段:Lua 里 nil + number 会抛错,而钩子抛错
会让**该请求完全不记账**(一个统计缺口会变成静默缺口)。add() 里做了回填。
UI 增加 fresh/cache/cache% 三列 + Cache hit rate KPI;未上报的显示 n/r 而不是 0%。
## 2. Billing 页空白:render() 引用了未定义的 s
`render(st)` 里两处 KPI 写成 `s.degraded_reqs`,ReferenceError 让整个渲染
中断,所有表格停在初始的空 innerHTML。症状是「页面加载了但什么都没有」,
而 /api/plugins/billing/state 返回 200 且有真实数据 —— 载荷完全正确,
DOM 是空的。
更糟的是 refresh() 里的 `catch (e) { /* never break the page */ }` 把错误
**静默吞掉**了:网络面板一切正常,页面什么都没有。现在 catch 会
console.error(仍然不抛,装饰性组件不该拖垮宿主页,但必须留痕)。
## 3. 侧栏图标
billing 声明 icon = "💰",而原生 tab 全是内联 SVG(stroke: currentColor)。
emoji 尺寸不对、不跟随主题。
WebUI 增加 pluginIconHTML:插件图标可以是文本,也可以是内联 SVG。
**SVG 走严格白名单**(tag + 属性都是 allowlist,不是 denylist)——
插件是在运维者浏览器里跑的第三方代码,不能"信任插件";但也不能直接拒绝
SVG,因为那是唯一能和原生 tab 视觉一致的方式。
用真实 Chromium 验证 12 个用例,全部挡住,包括 foreignObject 里嵌 HTML
命名空间 <img onerror> 这个经典绕过(整体丢弃,所以 img/onerror 也没了)。
★ node 里没有 DOMParser/jsdom,所以没法在单测里跑这个过滤器 —— 用正则近似
会得到一个"测试通过但浏览器里失效"的过滤器,这比没有测试更糟。
顺带修了过滤器的两个真缺陷:输出里嵌套了空 `<svg></svg>`,且 viewBox
是从包装元素读的(永远是 null)而不是插件自己的,所以任何自定义 viewBox
的图标都会丢失。
## 判据(新增 7 项,全部变异验证)
写「注入脚本能否正常执行」这个守卫时我错了四次:
1. 静态扫「已声明的名字」→ 把 HTML 字符串里的 CSS 类名(class/div/td)
全报成未定义
2. 用 CSS 选择器解析器查样式表 → 报样式表本身坏了
3. 只挂 process 的 uncaughtException → 脚本在 IIFE 里异步跑,错误是
unhandledRejection,判据对原 bug 全绿
4. 只查「有没有抛错」→ render() 开头是 `if (!st) return`,传错字段是
**静默 no-op**:不抛、不打日志、不报错,只是页面空白
最终判据是:在 node 里用 DOM stub 真跑一遍,同时要求「无异常」且
「至少写进一个容器」,并监听 console.error。变异验证:还原 s → 红;
render 收到 undefined 字段 → 红。
表头/行列数一致性也有守卫:row() 加了缓存列而表头没加时,表格会整体错位
(cache% 落到 completion 列下)—— 渲染正常、有数据、但要仔细看才发现。
## 生产验证
重启后价目表与累计账完整保留(1.17 亿 prompt tokens)。
新请求缓存统计生效:cache_hit 947,436 / cache_fresh 888,
cache_reported_reqs 7 / 395(其余来自旧 state,正是该字段存在的意义)。
真实浏览器:表格 3 行、KPI 7 项、表头 name/cost/reqs/prompt/fresh/cache/cache%/completion、
SVG 图标 currentColor 渲染、控制台无 billing 错误。391 个测试全绿。
## 另发现一个无关 bug(未修)
首页 stats 图表抛 IndexSizeError: arc 半径为负(-2),在 ui/index.html 的
paintStats 附近。属状态页图表,不在本次范围。
330 lines
13 KiB
Go
330 lines
13 KiB
Go
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")
|
|
}
|
|
header := js[hStart:]
|
|
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)
|
|
}
|
|
}
|