Files
traefik-llmgateway/admin_catalog_test.go
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

338 lines
11 KiB
Go

package traefikllmgateway
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
)
// --- GET /admin/api/catalog ---
func TestAdminCatalog_GateMatrix(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
h, _ := newAdminGatewayHandle(t, cfg)
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, adminCatalogPath, c.apiKey))
if rec.Code != c.want {
t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String())
}
})
}
}
// TestAdminCatalog_PriceSourceAndDisplayFree exercises the five billing
// price sources plus the ':free'-suffix display-only case (DECISIONS Q3)
// end to end through GET /admin/api/catalog — including N1's own
// regression (verify-redesign-final.md): "alpha/a-model-1" carries a
// `pricing:` override and NO modelMeta/discovery entry at all, so an
// earlier version (InputPerMTokUSD/OutputPerMTokUSD filled from
// buildAdminModelMetaView's own display-layer resolution instead of
// billingPriceSource's) reported PriceSource: "override" alongside nil
// prices — this table now pins both price fields to the ACTUAL billed
// rate for every priced source, and nil for free/unpriced.
func TestAdminCatalog_PriceSourceAndDisplayFree(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
cfg.Pricing = map[string]*ModelPricing{
"alpha/a-model-1": {InputPerM: 5, OutputPerM: 5}, // override on canonical, no modelMeta/discovery price at all
}
cfg.ModelMeta = map[string]*ModelMetaConfig{
"alpha/a-model-2": {Free: true}, // modelMeta free
}
cfg.Providers["zeta"].Models = []string{"z-model", "gpt-5", "some-model:free"}
h, _ := newAdminGatewayHandle(t, cfg)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminCatalogPath, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminCatalogResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
byID := map[string]adminCatalogModel{}
for _, p := range got.Providers {
for _, m := range p.Models {
byID[m.ID] = m
}
}
builtinGPT5 := builtinPricing["gpt-5"]
cases := []struct {
wantInputPerM *float64 // nil means "must be omitted/nil"
wantOutputPerM *float64
id string
wantSource string
wantDisplayFree bool
}{
{ptrFloat64(5), ptrFloat64(5), "alpha/a-model-1", priceSourceOverride, false},
{nil, nil, "alpha/a-model-2", priceSourceFree, false},
{ptrFloat64(builtinGPT5.InputPerM), ptrFloat64(builtinGPT5.OutputPerM), "zeta/gpt-5", priceSourceBuiltin, false},
{nil, nil, "zeta/some-model:free", priceSourceUnpriced, true},
{nil, nil, "zeta/z-model", priceSourceUnpriced, false},
}
for _, c := range cases {
got, ok := byID[c.id]
if !ok {
t.Errorf("model %q missing from catalog", c.id)
continue
}
if got.PriceSource != c.wantSource {
t.Errorf("model %q PriceSource = %q, want %q", c.id, got.PriceSource, c.wantSource)
}
if got.DisplayFree != c.wantDisplayFree {
t.Errorf("model %q DisplayFree = %v, want %v", c.id, got.DisplayFree, c.wantDisplayFree)
}
if !ptrFloat64Equal(got.InputPerMTokUSD, c.wantInputPerM) {
t.Errorf("model %q InputPerMTokUSD = %v, want %v", c.id, derefFloat64(got.InputPerMTokUSD), derefFloat64(c.wantInputPerM))
}
if !ptrFloat64Equal(got.OutputPerMTokUSD, c.wantOutputPerM) {
t.Errorf("model %q OutputPerMTokUSD = %v, want %v", c.id, derefFloat64(got.OutputPerMTokUSD), derefFloat64(c.wantOutputPerM))
}
}
}
// ptrFloat64/derefFloat64/ptrFloat64Equal are small nil-safe *float64
// helpers for TestAdminCatalog_PriceSourceAndDisplayFree's own table —
// adminCatalogModel's price fields are `*float64` (nil = "no billed
// rate", never a fabricated 0), so the table needs to express and
// compare that nil-ness directly rather than comparing bare float64s.
func ptrFloat64(v float64) *float64 { return &v }
func derefFloat64(p *float64) float64 {
if p == nil {
return 0
}
return *p
}
func ptrFloat64Equal(a, b *float64) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
return *a == *b
}
// TestAdminCatalog_AliasesGroupedByTarget pins each model's Aliases field
// to exactly the configured aliases whose Target names it, sorted.
func TestAdminCatalog_AliasesGroupedByTarget(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
cfg.ModelAliases = map[string]string{
"z-model-b-alias": "zeta/z-model",
"z-model-a-alias": "zeta/z-model",
}
h, _ := newAdminGatewayHandle(t, cfg)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminCatalogPath, "sk-admin1"))
var got adminCatalogResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
var zModel *adminCatalogModel
for _, p := range got.Providers {
for i := range p.Models {
if p.Models[i].ID == "zeta/z-model" {
zModel = &p.Models[i]
}
}
}
if zModel == nil {
t.Fatal("zeta/z-model missing from catalog")
}
want := []string{"z-model-a-alias", "z-model-b-alias"}
if len(zModel.Aliases) != len(want) || zModel.Aliases[0] != want[0] || zModel.Aliases[1] != want[1] {
t.Errorf("Aliases = %v, want %v (sorted)", zModel.Aliases, want)
}
if len(got.Aliases) != 2 {
t.Errorf("top-level Aliases = %d entries, want 2", len(got.Aliases))
}
}
// --- GET /admin/api/consumers ---
func TestAdminConsumers_GateMatrix(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
h, _ := newAdminGatewayHandle(t, cfg)
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, adminConsumersPath, c.apiKey))
if rec.Code != c.want {
t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String())
}
})
}
}
// TestAdminConsumers_UserSourceAndPersonalGrant exercises source
// (inline/file) and the personal-grant Providers/Models fields end to
// end through GET /admin/api/consumers.
func TestAdminConsumers_UserSourceAndPersonalGrant(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
cfg.Users.Inline = append(cfg.Users.Inline, &UserConfig{
Name: "grantee", Group: "agroup", APIKey: "sk-grantee",
Providers: []string{"alpha"}, Models: []string{"a-model-1"},
})
h, gw := newAdminGatewayHandle(t, cfg)
if err := gw.auth.replaceFileUsers([]*UserConfig{
{Name: "filed", Group: "agroup", APIKey: "sk-filed"},
}); err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminConsumersPath, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminConsumersResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
byName := map[string]adminConsumerUser{}
for _, u := range got.Users {
byName[u.Name] = u
}
if byName["alice"].Source != "inline" {
t.Errorf(`alice.Source = %q, want "inline"`, byName["alice"].Source)
}
if byName["filed"].Source != "file" {
t.Errorf(`filed.Source = %q, want "file"`, byName["filed"].Source)
}
if !byName["admin1"].Admin {
t.Errorf("admin1.Admin = false, want true")
}
g := byName["grantee"]
if len(g.Providers) != 1 || g.Providers[0] != "alpha" {
t.Errorf("grantee.Providers = %v, want [alpha]", g.Providers)
}
if len(g.Models) != 1 || g.Models[0] != "a-model-1" {
t.Errorf("grantee.Models = %v, want [a-model-1]", g.Models)
}
}
// TestAdminConsumers_ModelAccessCounts pins adminConsumerGroup's
// ModelAccess/AllowedProviders against the live catalog for a group with
// an explicit provider+model restriction.
func TestAdminConsumers_ModelAccessCounts(t *testing.T) {
t.Parallel()
cfg := newAdminTestConfig()
cfg.Groups["agroup"] = &GroupConfig{Providers: []string{"alpha"}, Models: []string{"a-model-1"}}
h, _ := newAdminGatewayHandle(t, cfg)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminConsumersPath, "sk-admin1"))
var got adminConsumersResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
var agroup *adminConsumerGroup
for i := range got.Groups {
if got.Groups[i].Name == "agroup" {
agroup = &got.Groups[i]
}
}
if agroup == nil {
t.Fatal("agroup missing from consumers response")
}
if agroup.AllowedProviders == nil {
t.Error("AllowedProviders = nil, want non-nil")
}
if len(agroup.AllowedProviders) != 1 || agroup.AllowedProviders[0] != "alpha" {
t.Errorf("AllowedProviders = %v, want [alpha]", agroup.AllowedProviders)
}
alpha, ok := agroup.ModelAccess["alpha"]
if !ok {
t.Fatal("ModelAccess[alpha] missing")
}
if alpha.Allowed != 1 || alpha.Total != 2 {
t.Errorf("ModelAccess[alpha] = %+v, want {Allowed:1 Total:2}", alpha)
}
zeta, ok := agroup.ModelAccess["zeta"]
if !ok {
t.Fatal("ModelAccess[zeta] missing")
}
if zeta.Allowed != 0 || zeta.Total != 1 {
t.Errorf("ModelAccess[zeta] = %+v, want {Allowed:0 Total:1}", zeta)
}
// zgroup has no restriction: unrestricted access to every provider.
var zgroup *adminConsumerGroup
for i := range got.Groups {
if got.Groups[i].Name == "zgroup" {
zgroup = &got.Groups[i]
}
}
if zgroup == nil {
t.Fatal("zgroup missing")
}
if len(zgroup.AllowedProviders) != 2 {
t.Errorf("zgroup.AllowedProviders = %v, want both providers", zgroup.AllowedProviders)
}
}
// TestAdminConsumers_UsersFilePathOnly pins the "usersFile path shown,
// contents never" risk note (plan §6): only the configured path appears,
// never any per-user data beyond what userSummary/groupSummary already
// carry.
func TestAdminConsumers_UsersFilePathOnly(t *testing.T) {
t.Parallel()
fp := filepath.Join(t.TempDir(), "users.json")
writeUsersDoc(t, fp, []*UserConfig{{Name: "filed", Group: "agroup", APIKey: "sk-filed"}}, time.Time{})
cfg := newAdminTestConfig()
cfg.Users.File = fp
h, _ := newAdminGatewayHandle(t, cfg)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, adminRequest(http.MethodGet, adminConsumersPath, "sk-admin1"))
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
}
var got adminConsumersResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.UsersFile != fp {
t.Errorf("UsersFile = %q, want %q", got.UsersFile, fp)
}
for _, u := range got.Users {
if u.Name == "filed" && u.Source != "file" {
t.Errorf("filed.Source = %q, want file", u.Source)
}
}
}