Files
ModelRouter/internal/gateway/ui_contract_test.go
JianFeeeee 0121d23f91 fix(tokens): 流式统计改用上游真实 usage,图片不再记 token
两处 token 单位错误,均影响 per-model 配额计费:

1. 流式路径的 prompt/completion 只是「字节÷3」估算。
   pumpStream 明明收到了上游最后一帧的真实 usage,却只发给客户端、
   从不回写审计记录,于是配额按估算值扣。生产实测同一请求:
   上游 prompt=37/completion=179 → 记账 27/262,prompt 低估 1.4x、
   completion 高估 1.5x(双向失真)。同模型流式 completion 中位数
   是非流式的 4-27 倍。非流式路径本就用真实值,两路不一致。
   修法:lastUsage 非零时写回 rec.Prompt/rec.Compl,估算降为兜底
   (上游不报 usage 时仍保留原估算行为)。

2. 图片请求把「图片张数」记成 completion_tokens。
   rec.Compl = int64(len(resp.ImageData)),len 是切片长度即张数
   (生产 38 条 image 记录全是 1),且被计入 token 总量。
   图片生成无 token 概念 ⇒ 新增 Req.ImageCount 独立字段,
   Prompt/Compl 归 0;UI 记录表 image 行改显示张数(新增 i18n thImgs)。

顺带补 TestUILocaleKeyParity:此前无人校验 zh/en 键集合一致,
单边加键不会报错,只会显示原始键名。

新增 token_units_test.go(定值上游 6 项),做过变异验证:
回退修复实测复现 stream=16/173 vs chat=44/100、image completion=3。
2026-09-28 22:19:12 +08:00

407 lines
13 KiB
Go

package gateway
import (
"fmt"
"regexp"
"strings"
"testing"
)
// The WebUI talks to this package through a tiny `api(path, options)` helper
// that wraps fetch(). fetch() defaults to GET when no method is given, so an
// options object without `method` silently turns a DELETE/PUT into a GET —
// the request hits the wrong branch of the handler and the user sees a
// confusing error like "use GET /api/keys" while nothing is deleted.
//
// This has now bitten twice: ab20f1b fixed delSource/delTemplate, and the very
// same pattern was still live in delKey (deleting a gateway key was impossible)
// and delAdapter. Reviewing 4000+ lines of inline JS by eye clearly does not
// catch it, so it is pinned here instead.
// apiCallRe finds the start of every api(...) call in the UI source.
var apiCallRe = regexp.MustCompile(`\bapi\(`)
// uiSource returns the embedded WebUI document.
func uiSource(t *testing.T) string {
t.Helper()
b, err := uiFS.ReadFile("ui/index.html")
if err != nil {
t.Fatalf("read embedded ui: %v", err)
}
return string(b)
}
// extractCall returns the source of the balanced api(...) call starting at the
// opening parenthesis index, plus the line number it starts on.
func extractCall(src string, openIdx int) (string, bool) {
depth := 0
for i := openIdx; i < len(src); i++ {
switch src[i] {
case '(':
depth++
case ')':
depth--
if depth == 0 {
return src[openIdx : i+1], true
}
case '\n':
// A call spanning more than ~25 lines is not one of ours; bail out
// rather than scanning the rest of the document.
if i-openIdx > 4000 {
return "", false
}
}
}
return "", false
}
func lineOf(src string, idx int) int {
return strings.Count(src[:idx], "\n") + 1
}
// TestUIAPICallsDeclareMethod asserts that every api() call passing an options
// object also declares an HTTP method (or is a GET that only passes an
// AbortSignal). Without this, fetch defaults to GET and mutating endpoints are
// never reached.
func TestUIAPICallsDeclareMethod(t *testing.T) {
src := uiSource(t)
var offenders []string
for _, m := range apiCallRe.FindAllStringIndex(src, -1) {
open := m[1] - 1 // index of '('
call, ok := extractCall(src, open)
if !ok {
continue
}
// No options object at all => a plain GET, which is fine.
if !strings.Contains(call, "{") {
continue
}
if strings.Contains(call, "method:") {
continue
}
// A read that only carries an AbortSignal is a deliberate GET.
if strings.Contains(call, "signal:") && !strings.Contains(call, "body:") {
continue
}
offenders = append(offenders, fmt.Sprintf("line %d: %s", lineOf(src, open), oneLine(call)))
}
if len(offenders) > 0 {
t.Fatalf("api() called with an options object but no method (fetch will send GET):\n %s",
strings.Join(offenders, "\n "))
}
}
// TestUIDeleteHelpersUseDelete pins the specific helpers that remove things.
// Each must issue a DELETE; a GET here is the exact bug users reported as
// "cannot delete key: use GET /api/keys".
func TestUIDeleteHelpersUseDelete(t *testing.T) {
src := uiSource(t)
for _, fn := range []string{"delKey", "delAdapter", "delSource", "delTemplate"} {
body, ok := jsFunctionBody(src, fn)
if !ok {
t.Errorf("helper %s() not found in the WebUI", fn)
continue
}
if !strings.Contains(body, "api(") {
t.Errorf("%s() does not call api()", fn)
continue
}
if !strings.Contains(body, `method: "DELETE"`) {
t.Errorf("%s() must issue a DELETE, got:\n%s", fn, body)
}
}
}
// TestUIMutatingHelpersUseWriteMethods covers the write (non-delete) helpers:
// each must declare an explicit POST/PUT/PATCH.
func TestUIMutatingHelpersUseWriteMethods(t *testing.T) {
src := uiSource(t)
want := map[string][]string{
"createKey": {`method: "POST"`},
"putScope": {`method: "PUT"`},
"uploadAdapter": {`method: "POST"`},
"saveAuto": {`method: "PUT"`, `method: "POST"`},
}
for fn, methods := range want {
body, ok := jsFunctionBody(src, fn)
if !ok {
continue // helper renamed or absent: the method test above still guards it
}
found := false
for _, m := range methods {
if strings.Contains(body, m) {
found = true
break
}
}
if !found {
t.Errorf("%s() must declare one of %v", fn, methods)
}
}
}
// jsFunctionBody returns the source of `function name(` / `async function name(`
// up to its closing brace, using brace balancing.
func jsFunctionBody(src, name string) (string, bool) {
for _, prefix := range []string{"async function " + name + "(", "function " + name + "("} {
idx := strings.Index(src, prefix)
if idx < 0 {
continue
}
open := strings.Index(src[idx:], "{")
if open < 0 {
continue
}
open += idx
depth := 0
for i := open; i < len(src); i++ {
switch src[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return src[idx : i+1], true
}
}
}
}
return "", false
}
func oneLine(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 140 {
return s[:140] + "…"
}
return s
}
// TestUIRecordsPagingWiring guards the record table's paging plumbing. Two
// separate bugs made "scroll to the bottom" silently stop loading:
//
// 1. paintRecords rebuilt the whole table on every 5s poll whenever the row
// count did not exceed the first screen, wiping loaded pages and resetting
// scroll position.
// 2. IntersectionObserver only fires on TRANSITIONS. With a short list — or a
// page whose rows all duplicated the first screen — the sentinel stayed
// visible and never fired again.
//
// The fixes are a build-once flag, a scroll-position fallback, an initial
// viewport fill, and chaining when a page yields no new rows. None of that is
// reachable from Go, so the wiring is asserted structurally.
func TestUIRecordsPagingWiring(t *testing.T) {
src := uiSource(t)
paint, ok := jsFunctionBody(src, "paintRecords")
if !ok {
t.Fatal("paintRecords() not found")
}
if !strings.Contains(paint, "recsState.built") {
t.Error("paintRecords must build the table once (recsState.built) instead of rebuilding on every poll")
}
if !strings.Contains(paint, "afterbegin") {
t.Error("paintRecords must PREPEND newer rows on later polls, not rebuild the table")
}
if !strings.Contains(paint, "fillRecordsViewport") {
t.Error("paintRecords must fill the viewport so the sentinel can transition")
}
attach, ok := jsFunctionBody(src, "attachRecsObserver")
if !ok {
t.Fatal("attachRecsObserver() not found")
}
if !strings.Contains(attach, "IntersectionObserver") {
t.Error("attachRecsObserver must still use IntersectionObserver")
}
if !strings.Contains(attach, `addEventListener("scroll"`) {
t.Error("attachRecsObserver needs a scroll fallback: an already-visible sentinel never fires again")
}
load, ok := jsFunctionBody(src, "loadMoreRecords")
if !ok {
t.Fatal("loadMoreRecords() not found")
}
if !strings.Contains(load, "maxChain") {
t.Error("loadMoreRecords must chain when a page adds no new rows (the first page overlaps the first screen)")
}
if !strings.Contains(load, "signal: ctrl.signal") {
t.Error("loadMoreRecords must remain abortable")
}
fill, ok := jsFunctionBody(src, "fillRecordsViewport")
if !ok {
t.Fatal("fillRecordsViewport() not found")
}
for _, want := range []string{"scrollHeight", "clientHeight"} {
if !strings.Contains(fill, want) {
t.Errorf("fillRecordsViewport must compare %s to decide whether the list overflows", want)
}
}
rel, ok := jsFunctionBody(src, "releaseRecords")
if !ok {
t.Fatal("releaseRecords() not found")
}
for _, want := range []string{"observer.disconnect()", "removeEventListener", "abort()", "recsState.built = false"} {
if !strings.Contains(rel, want) {
t.Errorf("releaseRecords must clean up %s", want)
}
}
}
// TestUIThemeMatrix pins the theme system: every accent must define both a light
// and a dark variable block, so switching light/dark can never leave a theme
// half-defined (which shows up as unreadable text rather than as an error).
func TestUIThemeMatrix(t *testing.T) {
src := uiSource(t)
accents := []string{"sakura", "ocean", "violet", "mono"}
for _, a := range accents {
light := fmt.Sprintf(`html[data-accent=%q]{`, a)
dark := fmt.Sprintf(`html[data-theme="dark"][data-accent=%q]{`, a)
if !strings.Contains(src, light) {
t.Errorf("accent %q has no light-mode variable block (%s)", a, light)
}
// sakura is the default palette: its light values live in :root, and the
// generic dark block covers it.
if a != "sakura" && !strings.Contains(src, dark) {
t.Errorf("accent %q has no dark-mode variable block (%s)", a, dark)
}
if !strings.Contains(src, fmt.Sprintf(`data-accent="%s" title=`, a)) {
t.Errorf("accent %q has no picker button", a)
}
}
}
// TestUIMonoThemeIsFlat asserts the mono theme really is pure white / pure black:
// the surfaces must be opaque and the glass/blob decoration must be switched off,
// otherwise "#ffffff" renders as a grey translucent card over a gradient mesh.
func TestUIMonoThemeIsFlat(t *testing.T) {
src := uiSource(t)
lightBlock, ok := cssBlock(src, `html[data-accent="mono"]{`)
if !ok {
t.Fatal("mono light block not found")
}
for _, want := range []string{"--bg-s1:#ffffff", "--card:#ffffff", "--glass:0px"} {
if !strings.Contains(lightBlock, want) {
t.Errorf("mono light theme must set %s, got:\n%s", want, lightBlock)
}
}
if !strings.Contains(lightBlock, "--blob1:transparent") {
t.Error("mono light theme must disable the gradient blobs")
}
darkBlock, ok := cssBlock(src, `html[data-theme="dark"][data-accent="mono"]{`)
if !ok {
t.Fatal("mono dark block not found")
}
for _, want := range []string{"--bg-s1:#000000", "--glass:0px"} {
if !strings.Contains(darkBlock, want) {
t.Errorf("mono dark theme must set %s, got:\n%s", want, darkBlock)
}
}
if !strings.Contains(darkBlock, "--blob1:transparent") {
t.Error("mono dark theme must disable the gradient blobs")
}
// the flat-surface overrides must exist, or backdrop-filter turns #fff grey
for _, want := range []string{
`html[data-accent="mono"] #bgfx .blob{display:none}`,
`backdrop-filter:none`,
} {
if !strings.Contains(src, want) {
t.Errorf("mono theme needs the flat-surface override %q", want)
}
}
}
// TestUICanvasColorsResolveVars guards a class of silent theme bug: Canvas 2D
// does not understand CSS var(), so assigning "var(--x)" to strokeStyle is a
// no-op that leaves the previous colour (often black) in place — invisible on a
// dark background. Theme-driven canvas colours must go through cssVar().
func TestUICanvasColorsResolveVars(t *testing.T) {
src := uiSource(t)
if !strings.Contains(src, "function cssVar(") {
t.Fatal("cssVar() helper missing: canvas colours cannot resolve CSS variables without it")
}
for _, prop := range []string{"strokeStyle", "fillStyle"} {
bad := fmt.Sprintf(`ctx.%s = "var(--`, prop)
if strings.Contains(src, bad) {
t.Errorf("ctx.%s assigned a raw CSS var(): Canvas ignores it silently, use cssVar()", prop)
}
}
}
// cssBlock returns the text of the CSS rule starting at selector, up to its
// closing brace.
func cssBlock(src, selector string) (string, bool) {
i := strings.Index(src, selector)
if i < 0 {
return "", false
}
j := strings.Index(src[i:], "}")
if j < 0 {
return "", false
}
return src[i : i+j+1], true
}
// The WebUI ships two locale objects (`zh` and `en`) that every rendered string
// goes through. A key added to only one of them does not error: t() falls back
// to `t("key") || "literal"` in some call sites and to the bare key string in
// others, so the user sees either a hardcoded local string or a raw key name.
// Nothing in the Go test suite noticed — this was found by hand while adding
// thImgs. Pin the key sets so a one-sided edit fails here instead of shipping.
func TestUILocaleKeyParity(t *testing.T) {
src := uiSource(t)
iZh := strings.Index(src, "zh: {")
iEn := strings.Index(src, "en: {")
if iZh < 0 || iEn < 0 || iEn < iZh {
t.Fatalf("locale blocks not found (zh=%d en=%d)", iZh, iEn)
}
zh := src[iZh:iEn]
en := src[iEn:]
// End the EN block at its closing brace, so keys from later objects
// (config templates etc.) do not pollute the comparison.
if end := strings.Index(en, "\n },"); end > 0 {
en = en[:end]
}
keyRe := regexp.MustCompile(`(?m)^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:`)
collect := func(seg string) map[string]bool {
m := map[string]bool{}
for _, g := range keyRe.FindAllStringSubmatch(seg, -1) {
m[g[1]] = true
}
return m
}
zk, ek := collect(zh), collect(en)
// The locale markers themselves are the two block headers.
delete(zk, "zh")
delete(ek, "en")
var missingEN, missingZH []string
for k := range zk {
if !ek[k] {
missingEN = append(missingEN, k)
}
}
for k := range ek {
if !zk[k] {
missingZH = append(missingZH, k)
}
}
if len(missingEN) > 0 {
t.Errorf("translation keys present in zh but missing in en: %v", missingEN)
}
if len(missingZH) > 0 {
t.Errorf("translation keys present in en but missing in zh: %v", missingZH)
}
if len(zk) < 100 {
t.Errorf("only %d zh keys parsed — the block boundary regexp has drifted "+
"and this test is no longer checking anything", len(zk))
}
}