Files
traefik-llmgateway/admin_totals_test.go
T
lukaszraczylo 65b2f121c4 feat(admin): add fleet statistics and admin read endpoints
Counters (always on when admin is enabled):
- last seen per user and group, written asynchronously after
  admission, throttled to one write per scope per minute per replica
- hourly provider and model attempts, failures and timeouts
- failovers per provider, rejections per hour, and unpriced (402)
  refusals
- cache hits, misses and cost saved
- request counts per target and caller

Opt-in counters under admin.stats (both default off):
- userModel: usage per user and model
- latency: latency histograms per provider and model

All counters ride the existing Redis batches. A test measures three
round trips per request with defaults and with every statistic on. A
cache hit takes two. A model request is no longer counted for failed
failover candidates.

New admin endpoints:
- usage/series and usage/totals
- performance, with percentiles from the histograms
- catalog, with the price billing actually uses and its source
- consumers, with key source, grants and model access
- config, with secrets redacted

The overview gains features and a pricing summary. Usage gains
lastSeen. usage/models gains offset and more detail fields. Reads are
chunked at 1600 keys per round trip and capped at 64000 keys per
request.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
2026-09-23 16:40:45 +01:00

291 lines
11 KiB
Go

package traefikllmgateway
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// --- gate ---
func TestAdminUsageTotals_GateMatrix(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
h, _ := newAdminGatewayHandle(t, cfg)
path := adminUsageTotalsPath + "?kind=user"
cases := []struct {
name string
apiKey string
want int
}{
{"unauthenticated", "", http.StatusUnauthorized},
{"non-admin", "sk-alice", http.StatusForbidden},
{"admin", "sk-admin1", http.StatusOK},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, path, c.apiKey))
if rec.Code != c.want {
t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String())
}
})
}
}
// --- kind matrix / param validation ---
func TestAdminUsageTotals_KindMatrix(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
h, _ := newAdminGatewayHandle(t, cfg)
cases := []struct {
name string
qs string
want int
}{
{"unknown kind", "?kind=bogus", http.StatusBadRequest},
{"user", "?kind=user", http.StatusOK},
{"group", "?kind=group", http.StatusOK},
{"usermodel missing user", "?kind=usermodel", http.StatusBadRequest},
{"usermodel disabled", "?kind=usermodel&user=alice", http.StatusNotFound},
{"modeluser missing model", "?kind=modeluser", http.StatusBadRequest},
{"modeluser disabled", "?kind=modeluser&model=alpha/a-model-1", http.StatusNotFound},
{"targetcaller", "?kind=targetcaller", http.StatusOK},
{"invalid window for usermodel", "?kind=targetcaller&window=hour", http.StatusBadRequest},
{"invalid metrics", "?kind=user&metrics=bogus", http.StatusBadRequest},
{"invalid limit", "?kind=user&limit=0", http.StatusBadRequest},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+c.qs, "sk-admin1"))
if rec.Code != c.want {
t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String())
}
})
}
}
// TestAdminUsageTotals_UserModelEnabled404sWhenOff pins that BOTH
// usermodel and modeluser 404 while admin.stats.userModel is off — the
// default (DECISIONS Q4) — and both succeed once it is on.
func TestAdminUsageTotals_UserModelEnabled404sWhenOff(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
h, _ := newAdminGatewayHandle(t, cfg)
for _, qs := range []string{"?kind=usermodel&user=alice", "?kind=modeluser&model=alpha/a-model-1"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+qs, "sk-admin1"))
if rec.Code != http.StatusNotFound {
t.Errorf("qs=%q: status = %d, want 404 (userModel stats off)", qs, rec.Code)
}
}
cfg2 := newAdminTestConfig()
cfg2.Admin.Stats = &AdminStatsConfig{UserModel: true}
h2, _ := newAdminGatewayHandle(t, cfg2)
for _, qs := range []string{"?kind=usermodel&user=alice", "?kind=modeluser&model=alpha/a-model-1"} {
rec := httptest.NewRecorder()
h2.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+qs, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Errorf("qs=%q: status = %d, want 200 (userModel stats on), body=%s", qs, rec.Code, rec.Body.String())
}
}
}
// TestAdminUsageTotals_UsermodelTwoPhaseRead seeds one candidate model
// with nonzero req in the cheap-phase covering window, and one with none
// at all, then confirms only the nonzero candidate's actual umodel
// counter is read and returned — the zero-req model never reaches the
// second (per-user) phase at all, pinning the "usermodel candidates via
// cheap phase... then umodel keys for nonzero candidates" optimization
// (plan §1.3(v)).
func TestAdminUsageTotals_UsermodelTwoPhaseRead(t *testing.T) {
t.Parallel()
fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
cfg := newAdminTestConfig()
cfg.Admin.Stats = &AdminStatsConfig{UserModel: true}
// Single-model catalog per provider keeps the cheap-phase scope small
// and the assertion unambiguous.
cfg.Providers["alpha"].Models = []string{"a-model-1"}
cfg.Providers["zeta"].Models = []string{"z-model"}
h, gw := newAdminGatewayHandle(t, cfg)
monthAnchor := historyStepBack(fixedNow, windowMonth, 0)
umodelID := userModelScopeID("alice", "alpha/a-model-1")
store := &countingMultiStore{values: map[string]int64{
// Cheap phase: alpha/a-model-1 has usage this month, zeta/z-model
// does not (absent key == 0 already, nothing to seed).
windowKey(kindModel, "alpha/a-model-1", metricReq, windowMonth, monthAnchor): 5,
// Second phase: alice's own umodel counters for that one candidate.
windowKey(kindUserModel, umodelID, metricReq, windowDay, fixedNow): 3,
windowKey(kindUserModel, umodelID, metricCost, windowDay, fixedNow): 9000,
windowKey(kindUserModel, umodelID, metricTokIn, windowDay, fixedNow): 0,
windowKey(kindUserModel, umodelID, metricTokOut, windowDay, fixedNow): 0,
}}
gw.limiter = newLimiter(store, true)
gw.limiter.nowFn = func() time.Time { return fixedNow }
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+"?kind=usermodel&user=alice&window=day", "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminTotalsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Rows) != 1 {
t.Fatalf("Rows = %+v, want exactly 1 row (alpha/a-model-1)", got.Rows)
}
row := got.Rows[0]
if row.ID != "alpha/a-model-1" {
t.Errorf("Rows[0].ID = %q, want alpha/a-model-1", row.ID)
}
if row.Values[metricReq] != 3 || row.Values[metricCost] != 9000 {
t.Errorf("Rows[0].Values = %+v, want req=3 cost=9000", row.Values)
}
}
// TestAdminUsageTotals_DropsAllZeroRows pins "Drop all-zero rows" (plan
// §1.3(v)) for the user kind.
func TestAdminUsageTotals_DropsAllZeroRows(t *testing.T) {
t.Parallel()
fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
cfg := newAdminTestConfig()
h, gw := newAdminGatewayHandle(t, cfg)
store := &countingMultiStore{values: map[string]int64{
windowKey("user", "alice", metricReq, windowDay, fixedNow): 4,
// admin1's own row: every requested metric stays 0 (unseeded).
}}
gw.limiter = newLimiter(store, true)
gw.limiter.nowFn = func() time.Time { return fixedNow }
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+"?kind=user&window=day", "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminTotalsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
for _, row := range got.Rows {
if row.ID == "admin1" {
t.Errorf("all-zero row for admin1 present, want dropped: %+v", row)
}
}
}
// TestAdminUsageTotals_StoreDownIs503 mirrors the series/models endpoints'
// own store-failure contract.
func TestAdminUsageTotals_StoreDownIs503(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
h, gw := newAdminGatewayHandle(t, cfg)
gw.limiter = newLimiter(alwaysErrStore{}, false)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+"?kind=user", "sk-admin1"))
if rec.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503, body=%s", rec.Code, rec.Body.String())
}
}
// TestAdminUsageTotals_TargetCallerRowID pins the targetcaller row id
// shape ("mcp/{target}/{caller}" — plan's own "mcp/fetch/alice" example).
func TestAdminUsageTotals_TargetCallerRowID(t *testing.T) {
t.Parallel()
fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
cfg := newAdminTestConfig()
cfg.MCPServers = map[string]*TargetConfig{"fetch": {URL: "http://fetch.invalid"}}
h, gw := newAdminGatewayHandle(t, cfg)
id := targetCallerScopeID("mcp", "fetch", "alice")
store := &countingMultiStore{values: map[string]int64{
windowKey(kindTargetCaller, id, metricReq, windowDay, fixedNow): 2,
}}
gw.limiter = newLimiter(store, true)
gw.limiter.nowFn = func() time.Time { return fixedNow }
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+"?kind=targetcaller&window=day", "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminTotalsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
var found bool
for _, row := range got.Rows {
if row.ID == "mcp/fetch/alice" {
found = true
if row.Values[metricReq] != 2 {
t.Errorf("row values = %+v, want req=2", row.Values)
}
}
}
if !found {
t.Errorf("Rows = %+v, want a row id 'mcp/fetch/alice'", got.Rows)
}
}
// TestReadSpanTotals_ChunksAtUsageModelsChunkKeys is P6/P12 (admin
// dashboard redesign verify round): readSpanTotals — the reader behind
// GET /admin/api/usage/totals' kind=user/group/usermodel/targetcaller
// rows and GET /admin/api/performance's own aggregate (series=0) mode —
// used to build ONE unbounded storeGetMulti pipeline covering its whole
// ids*metrics*span key set. It now delegates to the shared, chunked
// spanTotalsMulti reader (limits.go) that also backs admin.go's
// chunkedModelSpanTotals/Multi (TestChunkedModelSpanTotals_
// SplitsIntoMultipleRoundTrips, admin_test.go, pins that side).
func TestReadSpanTotals_ChunksAtUsageModelsChunkKeys(t *testing.T) {
t.Parallel()
fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
const n = 200
const span = 48
ids := make([]string, n)
for i := range ids {
ids[i] = fmt.Sprintf("u%03d", i)
}
store := &countingMultiStore{values: map[string]int64{}}
l := newLimiter(store, true)
l.nowFn = func() time.Time { return fixedNow }
gw := &Gateway{limiter: l}
metrics := []string{metricProvAttempt, metricProvFail}
got, ok := gw.readSpanTotals("user", ids, metrics, windowHour, fixedNow, span, 0)
if !ok {
t.Fatal("want ok=true")
}
if len(got) != n {
t.Fatalf("len(got) = %d, want %d", len(got), n)
}
// wantCalls mirrors spanTotalsMulti's own chunk-by-ID-count math
// (limits.go): perChunk = usageModelsChunkKeys / (span*len(metrics)),
// truncated, so a chunk can carry fewer than usageModelsChunkKeys keys
// when that division does not land evenly — chunking by whole ids,
// never splitting one id's own key range across two round trips, so
// the per-chunk key count is not always maximally packed.
perChunk := usageModelsChunkKeys / (len(metrics) * span)
wantCalls := (n + perChunk - 1) / perChunk
if store.getMultiCalls != wantCalls {
t.Errorf("getMultiCalls = %d, want %d (%d ids, %d ids/chunk)", store.getMultiCalls, wantCalls, n, perChunk)
}
if store.getMultiCalls <= 1 {
t.Error("want more than 1 round trip — otherwise this test proves nothing about chunking")
}
}