mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
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
This commit is contained in:
+112
@@ -430,6 +430,118 @@ func TestAuthStore_Snapshot_MultiGroupUser_ListsAllGroupsAndCountsEachMember(t *
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthStore_Snapshot_SourceInlineVsFile (admin dashboard redesign,
|
||||
// WP-B) pins userSummary.source: an inline user reports "inline", and a
|
||||
// file-sourced user — even one that overrides an inline user of the same
|
||||
// name (replaceFileUsers' own doc comment) — reports "file".
|
||||
func TestAuthStore_Snapshot_SourceInlineVsFile(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
|
||||
Groups: map[string]*GroupConfig{"eng": {}},
|
||||
Users: &UsersConfig{Inline: []*UserConfig{
|
||||
{Name: "inline-only", Group: "eng", APIKey: "sk-inline"},
|
||||
{Name: "overridden", Group: "eng", APIKey: "sk-old"},
|
||||
}},
|
||||
}
|
||||
a, err := newAuthStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.replaceFileUsers([]*UserConfig{
|
||||
{Name: "overridden", Group: "eng", APIKey: "sk-new"},
|
||||
{Name: "file-only", Group: "eng", APIKey: "sk-file"},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
users, _ := a.snapshot()
|
||||
got := map[string]string{}
|
||||
for _, u := range users {
|
||||
got[u.name] = u.source
|
||||
}
|
||||
want := map[string]string{"inline-only": "inline", "overridden": "file", "file-only": "file"}
|
||||
for name, wantSource := range want {
|
||||
if got[name] != wantSource {
|
||||
t.Errorf("source[%q] = %q, want %q", name, got[name], wantSource)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthStore_Snapshot_AdminAndPersonalGrant (admin dashboard redesign,
|
||||
// WP-B) pins userSummary.admin and personalProviders/personalModels: a
|
||||
// user with no personal grant reports both nil; a user with one reports
|
||||
// exactly its own Providers/Models, not the member group's resolved
|
||||
// access.
|
||||
func TestAuthStore_Snapshot_AdminAndPersonalGrant(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
|
||||
Groups: map[string]*GroupConfig{"eng": {Providers: []string{"anthropic"}}},
|
||||
Users: &UsersConfig{Inline: []*UserConfig{
|
||||
{Name: "plain", Group: "eng", APIKey: "sk-plain"},
|
||||
{Name: "admin-user", Group: "eng", APIKey: "sk-admin", Admin: true},
|
||||
{Name: "grantee", Group: "eng", APIKey: "sk-grant", Providers: []string{"openai"}, Models: []string{"gpt-5"}},
|
||||
}},
|
||||
}
|
||||
a, err := newAuthStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
users, _ := a.snapshot()
|
||||
byName := map[string]userSummary{}
|
||||
for _, u := range users {
|
||||
byName[u.name] = u
|
||||
}
|
||||
|
||||
if byName["plain"].admin {
|
||||
t.Error(`users["plain"].admin = true, want false`)
|
||||
}
|
||||
if !byName["admin-user"].admin {
|
||||
t.Error(`users["admin-user"].admin = false, want true`)
|
||||
}
|
||||
if byName["plain"].personalProviders != nil || byName["plain"].personalModels != nil {
|
||||
t.Errorf("plain user's personal grant = (%v, %v), want (nil, nil)", byName["plain"].personalProviders, byName["plain"].personalModels)
|
||||
}
|
||||
g := byName["grantee"]
|
||||
if len(g.personalProviders) != 1 || g.personalProviders[0] != "openai" {
|
||||
t.Errorf("grantee personalProviders = %v, want [openai]", g.personalProviders)
|
||||
}
|
||||
if len(g.personalModels) != 1 || g.personalModels[0] != "gpt-5" {
|
||||
t.Errorf("grantee personalModels = %v, want [gpt-5]", g.personalModels)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthStore_Snapshot_GroupPassthroughPaths (admin dashboard redesign,
|
||||
// WP-B) pins groupSummary.passthroughPaths: carried through unchanged
|
||||
// from GroupConfig.PassthroughPaths, cloned (not aliased) like the other
|
||||
// access-list fields.
|
||||
func TestAuthStore_Snapshot_GroupPassthroughPaths(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
|
||||
Groups: map[string]*GroupConfig{
|
||||
"eng": {PassthroughPaths: []string{"v1/chat/completions"}},
|
||||
"unrestrict": {},
|
||||
},
|
||||
Users: &UsersConfig{Inline: []*UserConfig{
|
||||
{Name: "a", Group: "eng", APIKey: "sk-a"},
|
||||
}},
|
||||
}
|
||||
a, err := newAuthStore(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, groups := a.snapshot()
|
||||
byName := map[string]groupSummary{}
|
||||
for _, g := range groups {
|
||||
byName[g.name] = g
|
||||
}
|
||||
if len(byName["eng"].passthroughPaths) != 1 || byName["eng"].passthroughPaths[0] != "v1/chat/completions" {
|
||||
t.Errorf(`groups["eng"].passthroughPaths = %v, want ["v1/chat/completions"]`, byName["eng"].passthroughPaths)
|
||||
}
|
||||
if byName["unrestrict"].passthroughPaths != nil {
|
||||
t.Errorf(`groups["unrestrict"].passthroughPaths = %v, want nil`, byName["unrestrict"].passthroughPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAuthStore_DuplicateAPIKey_ReturnsError(t *testing.T) {
|
||||
cfg := &Config{
|
||||
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
|
||||
|
||||
Reference in New Issue
Block a user