mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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
291 lines
11 KiB
Go
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")
|
|
}
|
|
}
|