mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-10-03 23:54:06 +00:00
两件事。 一、per-key AUTO 链的 UI 之前自己写了一套表格,只有 model + tier 两列, 比全局 AUTO 编辑器弱:没有拖拽排序、没有每槽位的 quota/period/hours。 这是重复实现且必然漂移。改为复用同一套泳道画布: - 抽出 buildSortIndex / buildLanes / lanesToRules / renderSortEditor, 全局编辑器与 per-key 编辑器共用。 - sortState.scope 决定保存目标(null=全局,key=该密钥),persistAuto 按此分支;空 lane 列表即"恢复继承",与后端空 PUT 等价。 - 画布查找走 sortCanvasEl(),两个 id 都能解析,否则拖拽在弹窗里静默失效。 - keyAutoClose 必须清 scope,否则关掉弹窗后全局编辑器会存到这个 key。 - 删掉旧的 keyAutoRow/keyAutoAddRow/keyAutoClear。 判据改为断言"复用"而非断言具体实现,并新增两条: - 关闭弹窗不清 scope(全局编辑器会存错目标) - per-key 编辑器不再挂载共享画布 二、"周视图总量大于月视图"不是 bug:日历周期并非嵌套。"本周"从周一开始, 所以月初会回溯到上月末几天,本周总量合法地大于本月。实测今天 2026-10-03 (周六):周窗口 09-28→10-03(6 天,9.2B tokens),月窗口 10-01→10-03 (3 天,4.0B tokens)。 真正的缺陷是看不见这一点。统计页新增"统计区间:YYYY-MM-DD → YYYY-MM-DD (Nd)",让周 > 月 自解释。判据钉住这个事实,并做变异验证:把周窗口钳制到 月内(一个看起来很自然的"修复")会被两条判据同时抓住。 CDP 实测:周视图显示"2026-09-28 → 2026-10-03 (6d)",月视图显示 "2026-10-01 → 2026-10-03 (3d)";per-key 编辑器渲染出 36 条泳道 / 226 个 块,与全局编辑器同款。 Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
404 lines
16 KiB
Go
404 lines
16 KiB
Go
package gateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// auditPeriod writes records straight into an audit file, which is what the
|
|
// period aggregator reads. Going through Record() would also rotate/prune the
|
|
// file; here the file shape is the thing under test.
|
|
func auditPeriod(t *testing.T, path string, recs ...Req) {
|
|
t.Helper()
|
|
f, err := os.Create(path)
|
|
if err != nil {
|
|
t.Fatalf("create audit file: %v", err)
|
|
}
|
|
defer f.Close()
|
|
for _, r := range recs {
|
|
if r.Time == 0 {
|
|
t.Fatal("test record needs an explicit timestamp")
|
|
}
|
|
if r.Type == "" {
|
|
r.Type = "chat"
|
|
}
|
|
b, err := json.Marshal(r)
|
|
if err != nil {
|
|
t.Fatalf("marshal record: %v", err)
|
|
}
|
|
if _, err := f.Write(append(b, '\n')); err != nil {
|
|
t.Fatalf("write record: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func periodStats(t *testing.T, auditPath string) *Stats {
|
|
t.Helper()
|
|
s := NewStats(10)
|
|
s.mu.Lock()
|
|
s.auditPath = auditPath
|
|
s.mu.Unlock()
|
|
return s
|
|
}
|
|
|
|
// TestPeriodWindowBoundaries is the boundary contract: a day window covers
|
|
// exactly today-from-midnight, a week window starts Monday (not Sunday — the
|
|
// Go Weekday() trap), and a month window starts on the 1st. All UTC.
|
|
func TestPeriodWindowBoundaries(t *testing.T) {
|
|
// Wednesday 2026-03-11 15:30 UTC.
|
|
now := time.Date(2026, 3, 11, 15, 30, 0, 0, time.UTC)
|
|
day := time.Date(2026, 3, 11, 0, 0, 0, 0, time.UTC)
|
|
if got := time.UnixMilli(periodStart(PeriodDay, now)).UTC(); !got.Equal(day) {
|
|
t.Errorf("day window starts %s, want %s", got, day)
|
|
}
|
|
// Wednesday minus 2 days = Monday the 9th.
|
|
monday := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC)
|
|
if got := time.UnixMilli(periodStart(PeriodWeek, now)).UTC(); !got.Equal(monday) {
|
|
t.Errorf("week window starts %s, want Monday %s", got, monday)
|
|
}
|
|
// Same instant on a Sunday must still start on the PRECEDING Monday,
|
|
// never on the Sunday itself. This is the off-by-one that
|
|
// int(now.Weekday()) would introduce (Sunday=0 → no shift).
|
|
sun := time.Date(2026, 3, 15, 10, 0, 0, 0, time.UTC) // Sunday
|
|
prevMon := time.Date(2026, 3, 9, 0, 0, 0, 0, time.UTC)
|
|
if got := time.UnixMilli(periodStart(PeriodWeek, sun)).UTC(); !got.Equal(prevMon) {
|
|
t.Errorf("Sunday week window starts %s, want the Monday before it %s", got, prevMon)
|
|
}
|
|
first := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
|
if got := time.UnixMilli(periodStart(PeriodMonth, now)).UTC(); !got.Equal(first) {
|
|
t.Errorf("month window starts %s, want %s", got, first)
|
|
}
|
|
if got := periodStart(PeriodAll, now); got != 0 {
|
|
t.Errorf("all window from = %d, want 0 (unbounded)", got)
|
|
}
|
|
}
|
|
|
|
// TestPeriodAggregationExcludesOlderRecords is the reason the aggregator reads
|
|
// the audit file: a record outside the window must not be counted, even though
|
|
// it sits in the very same file, right next to records that are.
|
|
func TestPeriodAggregationExcludesOlderRecords(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "audit.jsonl")
|
|
now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC)
|
|
h := func(n int) int64 { return now.Add(-time.Duration(n) * time.Hour).UnixMilli() }
|
|
|
|
auditPeriod(t, path,
|
|
// inside today
|
|
Req{Time: h(1), Model: "m", Source: "s", Prompt: 100, Compl: 10, LatMs: 5, OK: true},
|
|
Req{Time: h(2), Model: "m", Source: "s", Prompt: 200, Compl: 20, LatMs: 7, OK: true},
|
|
// outside today (yesterday) but inside the week
|
|
Req{Time: now.AddDate(0, 0, -1).UnixMilli(), Model: "old", Source: "s", Prompt: 999, Compl: 99, OK: true},
|
|
)
|
|
s := periodStats(t, path)
|
|
|
|
day := s.PeriodSnapshot(PeriodDay, "", now)
|
|
if day.Total.Reqs != 2 {
|
|
t.Errorf("day window counted %d requests, want 2 (yesterday's record must be excluded)", day.Total.Reqs)
|
|
}
|
|
if day.Total.Prompt != 300 {
|
|
t.Errorf("day prompt tokens = %d, want 300", day.Total.Prompt)
|
|
}
|
|
|
|
week := s.PeriodSnapshot(PeriodWeek, "", now)
|
|
if week.Total.Reqs != 3 {
|
|
t.Errorf("week window counted %d requests, want 3", week.Total.Reqs)
|
|
}
|
|
if week.Total.Prompt != 1299 {
|
|
t.Errorf("week prompt tokens = %d, want 1299", week.Total.Prompt)
|
|
}
|
|
}
|
|
|
|
// TestPeriodDimensionsAggregateIndependently checks the by_* rows actually
|
|
// split by their own key rather than all collapsing into one row.
|
|
func TestPeriodDimensionsAggregateIndependently(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "audit.jsonl")
|
|
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
|
auditPeriod(t, path,
|
|
Req{Time: now.UnixMilli(), Key: "k1", Model: "alpha", Source: "s1", Prompt: 10, OK: true},
|
|
Req{Time: now.UnixMilli(), Key: "k1", Model: "beta", Source: "s2", Prompt: 20, OK: true},
|
|
Req{Time: now.UnixMilli(), Key: "k2", Model: "alpha", Source: "s1", Prompt: 30, OK: true},
|
|
)
|
|
s := periodStats(t, path)
|
|
got := s.PeriodSnapshot(PeriodDay, "", now)
|
|
|
|
if len(got.Models) != 2 {
|
|
t.Fatalf("by_model has %d rows, want 2: %+v", len(got.Models), got.Models)
|
|
}
|
|
// alpha = 10 + 30
|
|
for _, r := range got.Models {
|
|
if r.Name == "alpha" && r.Prompt != 40 {
|
|
t.Errorf("alpha prompt = %d, want 40", r.Prompt)
|
|
}
|
|
}
|
|
if len(got.Srcs) != 2 {
|
|
t.Errorf("by_source has %d rows, want 2: %+v", len(got.Srcs), got.Srcs)
|
|
}
|
|
if len(got.ByKey) != 2 {
|
|
t.Errorf("by_key has %d rows, want 2: %+v", len(got.ByKey), got.ByKey)
|
|
}
|
|
// The rows must add up to the total, or the dashboard shows a total that
|
|
// disagrees with its own table.
|
|
var sum int64
|
|
for _, r := range got.Models {
|
|
sum += r.Prompt
|
|
}
|
|
if sum != got.Total.Prompt {
|
|
t.Errorf("by_model prompts sum to %d but total is %d — the table would contradict the KPI", sum, got.Total.Prompt)
|
|
}
|
|
}
|
|
|
|
// TestPeriodDayBucketsAreHourlyAndOrdered pins the timeline grain and its
|
|
// order. Day = hourly (a spike must be visible inside "today"); week/month =
|
|
// daily. Sorted chronologically, because a chart fed unsorted buckets draws
|
|
// nonsense.
|
|
func TestPeriodDayBucketsAreHourlyAndOrdered(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "audit.jsonl")
|
|
now := time.Date(2026, 3, 11, 15, 0, 0, 0, time.UTC)
|
|
// Deliberately out of order in the file.
|
|
auditPeriod(t, path,
|
|
Req{Time: now.Add(-2 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true},
|
|
Req{Time: now.Add(-4 * time.Hour).UnixMilli(), Model: "m", Prompt: 1, OK: true},
|
|
Req{Time: now.UnixMilli(), Model: "m", Prompt: 1, OK: true},
|
|
)
|
|
s := periodStats(t, path)
|
|
got := s.PeriodSnapshot(PeriodDay, "", now)
|
|
if len(got.Bucket) != 3 {
|
|
t.Fatalf("day buckets = %d, want 3 (hourly): %+v", len(got.Bucket), got.Bucket)
|
|
}
|
|
for i := 1; i < len(got.Bucket); i++ {
|
|
if got.Bucket[i-1].Bucket >= got.Bucket[i].Bucket {
|
|
t.Errorf("day buckets not chronological at %d: %q >= %q",
|
|
i, got.Bucket[i-1].Bucket, got.Bucket[i].Bucket)
|
|
}
|
|
}
|
|
// Weekly grain is daily, so the same three records collapse to one day.
|
|
week := s.PeriodSnapshot(PeriodWeek, "", now)
|
|
if len(week.Bucket) != 1 {
|
|
t.Errorf("week buckets = %d, want 1 (daily grain): %+v", len(week.Bucket), week.Bucket)
|
|
}
|
|
}
|
|
|
|
// TestPeriodKeyFilterIsolatesKeys guards the multi-tenant boundary: asking for
|
|
// one key must never return another key's rows in any dimension.
|
|
func TestPeriodKeyFilterIsolatesKeys(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "audit.jsonl")
|
|
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
|
auditPeriod(t, path,
|
|
Req{Time: now.UnixMilli(), Key: "alice", Model: "m", Source: "s", Prompt: 10, OK: true},
|
|
Req{Time: now.UnixMilli(), Key: "bob", Model: "m", Source: "s", Prompt: 999, OK: true},
|
|
)
|
|
s := periodStats(t, path)
|
|
got := s.PeriodSnapshot(PeriodDay, "alice", now)
|
|
if got.Total.Reqs != 1 || got.Total.Prompt != 10 {
|
|
t.Errorf("alice's window = %d reqs / %d prompt, want 1 / 10 — another key leaked in",
|
|
got.Total.Reqs, got.Total.Prompt)
|
|
}
|
|
if len(got.Srcs) != 1 || got.Srcs[0].Prompt != 10 {
|
|
t.Errorf("by_source leaked: %+v", got.Srcs)
|
|
}
|
|
}
|
|
|
|
// TestPeriodAllUsesLifetimeAggregates checks "all" still answers from the
|
|
// in-memory aggregates (no audit file needed) and carries the same rows the
|
|
// dashboard already renders.
|
|
func TestPeriodAllUsesLifetimeAggregates(t *testing.T) {
|
|
s := NewStats(100)
|
|
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
|
s.Record(Req{Time: now.AddDate(0, 0, -40).UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 7, OK: true})
|
|
s.Record(Req{Time: now.UnixMilli(), Key: "k", Model: "m", Source: "s", Prompt: 3, OK: true})
|
|
|
|
got := s.PeriodSnapshot(PeriodAll, "", now)
|
|
if got.Total.Reqs != 2 || got.Total.Prompt != 10 {
|
|
t.Errorf("all view = %d reqs / %d prompt, want 2 / 10 (lifetime)", got.Total.Reqs, got.Total.Prompt)
|
|
}
|
|
if len(got.Models) != 1 || got.Models[0].Prompt != 10 {
|
|
t.Errorf("all view by_model = %+v, want one row with 10 prompt", got.Models)
|
|
}
|
|
// A lifetime total has no timeline to draw; buckets must stay empty
|
|
// rather than inventing 500 hourly points.
|
|
if len(got.Bucket) != 0 {
|
|
t.Errorf("all view produced %d buckets, want 0", len(got.Bucket))
|
|
}
|
|
}
|
|
|
|
// TestPeriodEmptyWindowFlagsTruncated covers the "rotated away" case: a bounded
|
|
// window with nothing to show may mean "quiet day" or "the files are gone".
|
|
// The aggregator must not report a confidently wrong zero.
|
|
func TestPeriodEmptyWindowFlagsTruncated(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s := periodStats(t, filepath.Join(dir, "audit.jsonl")) // file does not exist
|
|
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
|
got := s.PeriodSnapshot(PeriodDay, "", now)
|
|
if got.Total.Reqs != 0 {
|
|
t.Errorf("empty window reqs = %d, want 0", got.Total.Reqs)
|
|
}
|
|
if !got.Truncated {
|
|
t.Error("★ an empty bounded window must be flagged truncated — otherwise a " +
|
|
"rotated-away week reads as 'no traffic that week'")
|
|
}
|
|
}
|
|
|
|
// TestValidPeriodRejectsUnknown guards the deliberate strictness: a typo must
|
|
// be an error, not a silent fallback to lifetime totals.
|
|
func TestValidPeriodRejectsUnknown(t *testing.T) {
|
|
for _, ok := range []Period{PeriodDay, PeriodWeek, PeriodMonth, PeriodAll} {
|
|
if !ValidPeriod(ok) {
|
|
t.Errorf("ValidPeriod(%q) = false, want true", ok)
|
|
}
|
|
}
|
|
for _, bad := range []Period{"", "year", "hour", "today", "DAY "} {
|
|
if ValidPeriod(bad) {
|
|
t.Errorf("ValidPeriod(%q) = true, want false", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPeriodStatusDimensionCountsFailures covers by_status, which nothing else
|
|
// exercised — an aggregation branch with no test is exactly how a period view
|
|
// silently loses the failure pie. M8 (disabling the status dimension) is
|
|
// caught only because this test exists.
|
|
func TestPeriodStatusDimensionCountsFailures(t *testing.T) {
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "audit.jsonl")
|
|
now := time.Date(2026, 3, 11, 10, 0, 0, 0, time.UTC)
|
|
auditPeriod(t, path,
|
|
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 200, OK: true, Prompt: 5},
|
|
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1},
|
|
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 502, OK: false, Prompt: 1},
|
|
Req{Time: now.UnixMilli(), Model: "m", Source: "s", Status: 429, OK: false, Prompt: 1},
|
|
)
|
|
s := periodStats(t, path)
|
|
got := s.PeriodSnapshot(PeriodDay, "", now)
|
|
|
|
byCode := map[string]int64{}
|
|
for _, r := range got.Status {
|
|
byCode[r.Name] = r.Reqs
|
|
}
|
|
if len(got.Status) != 3 {
|
|
t.Fatalf("by_status has %d rows, want 3 (200/502/429): %+v", len(got.Status), got.Status)
|
|
}
|
|
if byCode["200"] != 1 || byCode["502"] != 2 || byCode["429"] != 1 {
|
|
t.Errorf("by_status counts = %v, want 200:1 502:2 429:1", byCode)
|
|
}
|
|
// Status codes must sort numerically: "429" before "502" lexically would
|
|
// render the failure legend in the wrong order.
|
|
if got.Status[0].Name != "200" || got.Status[1].Name != "429" || got.Status[2].Name != "502" {
|
|
t.Errorf("by_status not numerically sorted: %s, %s, %s",
|
|
got.Status[0].Name, got.Status[1].Name, got.Status[2].Name)
|
|
}
|
|
// The failure rows must agree with the OK/Err split on the total, or the
|
|
// pie and the KPI row contradict each other.
|
|
if got.Total.OK != 1 || got.Total.Err != 3 {
|
|
t.Errorf("total ok/err = %d/%d, want 1/3", got.Total.OK, got.Total.Err)
|
|
}
|
|
}
|
|
|
|
func TestPeriodFromQuery(t *testing.T) {
|
|
cases := []struct {
|
|
query string
|
|
want Period
|
|
}{
|
|
{"", PeriodAll}, // no selector → unchanged legacy behaviour
|
|
{"?period=day", PeriodDay},
|
|
{"?period=WEEK", PeriodWeek},
|
|
{"?period=month", PeriodMonth},
|
|
{"?period=all", PeriodAll},
|
|
}
|
|
for _, c := range cases {
|
|
r := httptest.NewRequest(http.MethodGet, "/api/stats"+c.query, nil)
|
|
if got := periodFromQuery(r.URL.Query()); got != c.want {
|
|
t.Errorf("periodFromQuery(%q) = %q, want %q", c.query, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPeriodWeekMayStartBeforeMonth pins a fact that looks like a bug and
|
|
// gets reported as one: the calendar periods are NOT nested.
|
|
//
|
|
// On 2026-10-03 (a Saturday) the ISO week runs Mon 2026-09-28 .. Sat
|
|
// 10-03, while the month window is 10-01 .. 10-03. The week therefore reaches
|
|
// back into the previous month and its total is legitimately LARGER than the
|
|
// month's. Anyone reading only the totals will conclude the aggregator is
|
|
// broken; it is not, and this test exists so a future "fix" that makes the
|
|
// windows nested (e.g. clamping the week to the month) is caught as the
|
|
// regression it would be.
|
|
func TestPeriodWeekMayStartBeforeMonth(t *testing.T) {
|
|
// Saturday 2026-10-03, UTC.
|
|
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
|
|
|
|
weekFrom := periodStart(PeriodWeek, now)
|
|
monthFrom := periodStart(PeriodMonth, now)
|
|
|
|
weekFromD := time.UnixMilli(weekFrom).UTC()
|
|
monthFromD := time.UnixMilli(monthFrom).UTC()
|
|
|
|
if got := weekFromD.Format("2006-01-02"); got != "2026-09-28" {
|
|
t.Fatalf("week window start = %s, want 2026-09-28 (the Monday)", got)
|
|
}
|
|
if got := monthFromD.Format("2006-01-02"); got != "2026-10-01" {
|
|
t.Fatalf("month window start = %s, want 2026-10-01", got)
|
|
}
|
|
if !weekFromD.Before(monthFromD) {
|
|
t.Fatal("this test only means anything when the week starts before the month")
|
|
}
|
|
// The week must reach strictly further back than the month window does,
|
|
// otherwise the nesting intuition would hold and the UI note would be noise.
|
|
if back := monthFromD.Sub(weekFromD).Hours() / 24; back <= 0 {
|
|
t.Fatalf("week should start at least a day before the month window, got %v days back", back)
|
|
}
|
|
}
|
|
|
|
// The same overlap must be visible in the aggregate numbers, not just in the
|
|
// window bounds: a week total larger than a month total is the exact symptom
|
|
// an operator reported.
|
|
func TestPeriodWeekTotalCanExceedMonthTotal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
audit := filepath.Join(dir, "audit.jsonl")
|
|
|
|
// Two records before the month window (inside this ISO week) and one
|
|
// inside October.
|
|
pre := time.Date(2026, 9, 29, 10, 0, 0, 0, time.UTC).UnixMilli()
|
|
in1 := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC).UnixMilli()
|
|
in2 := time.Date(2026, 10, 3, 10, 0, 0, 0, time.UTC).UnixMilli()
|
|
|
|
auditPeriod(t, audit,
|
|
Req{Time: pre, OK: true, Prompt: 100, Compl: 10},
|
|
Req{Time: pre + 1000, OK: true, Prompt: 100, Compl: 10},
|
|
Req{Time: in1, OK: true, Prompt: 50, Compl: 5},
|
|
Req{Time: in2, OK: true, Prompt: 50, Compl: 5},
|
|
)
|
|
|
|
s := periodStats(t, audit)
|
|
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
|
|
|
|
week := s.PeriodSnapshot(PeriodWeek, "", now)
|
|
month := s.PeriodSnapshot(PeriodMonth, "", now)
|
|
|
|
if week.Total.Reqs != 4 {
|
|
t.Fatalf("week reqs = %d, want 4 (both September records fall in this ISO week)", week.Total.Reqs)
|
|
}
|
|
if month.Total.Reqs != 2 {
|
|
t.Fatalf("month reqs = %d, want 2", month.Total.Reqs)
|
|
}
|
|
if week.Total.Tokens <= month.Total.Tokens {
|
|
t.Fatalf("week tokens (%d) must exceed month tokens (%d): the week reaches into September",
|
|
week.Total.Tokens, month.Total.Tokens)
|
|
}
|
|
// Day must be a strict subset of both, which is the only nesting that holds.
|
|
day := s.PeriodSnapshot(PeriodDay, "", now)
|
|
if day.Total.Reqs != 1 {
|
|
t.Fatalf("day reqs = %d, want 1", day.Total.Reqs)
|
|
}
|
|
if day.Total.Tokens >= month.Total.Tokens || day.Total.Tokens >= week.Total.Tokens {
|
|
t.Fatal("today must be contained in both the week and the month totals")
|
|
}
|
|
}
|