mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
Phase 2 收尾(入向)。ChatRequest 从"客户端发什么就是什么"改为
UnifiedRequest(最大集合)的别名,内核真正用上了那个超集类型。
改掉的具体缺陷:
1. 私有字段泄漏归零(v1 的核心问题)
v1 的 disable_thinking 是内核私有开关,却被序列化进交给适配器的 body,
迫使 10 个适配器各自写 req.disable_thinking = nil 来清理。
现在它是中立的 thinking 字段({enabled, budget_tokens}),客户端与
适配器都认这个词:
- gateway/chat.go:thinkingFromClient() 把 disable_thinking 翻译成
thinking.enabled=false;冲突时中立字段优先(它能带预算,布尔不能)
- deepseek.lua:thinking.enabled=false → extra_body.thinking.type=disabled
- anthropic.lua:thinking.enabled=true + budget → 原生 {type,budget_tokens}
(enabled=false 时不传该字段——Anthropic 没有 type=disabled 这个写法)
- 其余 7 个适配器:把"清理内核私有字段"改成"清理中立字段"
2. Tools/ToolChoice 改 json.RawMessage
原为 []interface{}(OpenAI 原始形状 = 内核在假设某个协议)。
内核从不解释工具 schema——它原样交给适配器,所以原始形状不损失任何
内核侧统一性。ToolDef 结构化类型保留在包内供新适配器选用。
3. 删除 MarshalJSON 后门
v1 手工 splice ExtraBody 进 OpenAI 形状的 body;现在 ExtraBody 是
UnifiedRequest 上一个正正经经声明的字段。
渐进迁移:ChatRequest 保留为别名(不是新类型),49 处引用零改动编译,
语义变更不被机械改名淹没。
测试:新增 thinking_test.go(翻译链 4 例 + 泄漏回归 + tools 透传);
vm_test.go 两处迁移到中立语义。golden 字节级未漂移。
500 lines
21 KiB
Go
500 lines
21 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")
|
||
}
|
||
// 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)
|
||
}
|
||
}
|