diff --git a/admin.go b/admin.go
new file mode 100644
index 0000000..c518115
--- /dev/null
+++ b/admin.go
@@ -0,0 +1,491 @@
+package traefikllmgateway
+
+import (
+ "encoding/json"
+ "io"
+ "net/http"
+ "time"
+)
+
+// pluginVersion is the gateway's own build/plugin version string, surfaced
+// read-only in GET /admin/api/overview (spec §4, v0.2). Bumped alongside
+// the module's own release process — not read from anywhere else in the
+// package, since Yaegi interpretation has no build-info mechanism to pull
+// it from automatically.
+const pluginVersion = "0.2.0"
+
+// The three routes the read-only admin dashboard registers (spec §4,
+// v0.2), matched only when adminEnabled(g.cfg) — see llmgateway.go's
+// ServeHTTP dispatch.
+const (
+ adminPagePath = "/admin"
+ adminOverviewPath = "/admin/api/overview"
+ adminUsagePath = "/admin/api/usage"
+)
+
+// adminCSP is the Content-Security-Policy header served with GET /admin
+// (spec §4, v0.2): no external assets of any kind, only this page's own
+// inline script/style, and fetch calls restricted to same-origin — the
+// dashboard works air-gapped and cannot be coerced into loading anything
+// off-host.
+const adminCSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'"
+
+// adminEnabled reports whether cfg's Admin block is present and enabled —
+// the single gate ServeHTTP checks before matching any /admin* route at
+// all. A nil or disabled block means those paths are simply not
+// registered, so they fall through to ServeHTTP's existing 404/
+// passthroughUnknown handling, exactly like any other unrecognized path.
+func adminEnabled(cfg *Config) bool {
+ return cfg.Admin != nil && cfg.Admin.Enabled
+}
+
+// isAdminPath reports whether path is one of the three admin routes.
+func isAdminPath(path string) bool {
+ return path == adminPagePath || path == adminOverviewPath || path == adminUsagePath
+}
+
+// handleAdmin is the shared entry point for all three /admin* routes,
+// applying spec §4's gate order: unauthenticated → 401, authenticated
+// non-admin → 403, admin → serve. It is called only when adminEnabled —
+// ServeHTTP's dispatch already checked that — and, like every other
+// authenticated route, counts request counters via checkAndCount before
+// serving: an admin over their own req/min limit gets a 429 here exactly
+// as they would on any other route (spec §4's "Accounting: admin routes
+// count request counters like any authed route").
+func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) {
+ u, grp, ok := g.auth.identify(r)
+ g.logAuthEvent(ok, authEventUserName(u), r)
+ if !ok {
+ writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
+ return
+ }
+ if !u.admin {
+ writeOAIError(sw, http.StatusForbidden, "invalid_request_error", "admin access required")
+ return
+ }
+ if violation := g.limiter.checkAndCount(buildLimitScopes(u, grp)); violation != nil {
+ writeLimitViolation(sw, violation)
+ return
+ }
+
+ switch r.URL.Path {
+ case adminPagePath:
+ g.serveAdminPage(sw)
+ case adminOverviewPath:
+ g.serveAdminOverview(sw)
+ case adminUsagePath:
+ g.serveAdminUsage(sw)
+ }
+}
+
+// serveAdminPage writes the single-page dashboard (adminPageHTML, below)
+// with its Content-Security-Policy header.
+func (g *Gateway) serveAdminPage(w http.ResponseWriter) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Content-Security-Policy", adminCSP)
+ _, _ = io.WriteString(w, adminPageHTML)
+}
+
+// adminProviderView is one provider's read-only view in GET
+// /admin/api/overview (spec §4, v0.2). baseUrl is not secret — the
+// spec's NEVER-exposed list is API keys (not even digests), provider
+// keys, the redis password, and users-file path contents; a provider's
+// base URL names no credential.
+type adminProviderView struct {
+ Name string `json:"name"`
+ Type string `json:"type"`
+ BaseURL string `json:"baseUrl"`
+ LastRefresh time.Time `json:"lastRefresh"`
+ LastErr string `json:"lastErr,omitempty"`
+ ModelCount int `json:"modelCount"`
+}
+
+// adminRedisView is the redis status line in GET /admin/api/overview.
+type adminRedisView struct {
+ LastErr string `json:"lastErr,omitempty"`
+ Configured bool `json:"configured"`
+}
+
+// adminCacheView is the cache config summary in GET /admin/api/overview —
+// enabled/ttl only, per spec §4: no live hit counters (the spec makes no
+// such promise).
+type adminCacheView struct {
+ TTL string `json:"ttl,omitempty"`
+ Enabled bool `json:"enabled"`
+}
+
+// adminGroupView is one group's summary in GET /admin/api/overview.
+type adminGroupView struct {
+ Limits *LimitsConfig `json:"limits,omitempty"`
+ Name string `json:"name"`
+ MemberCount int `json:"memberCount"`
+}
+
+// adminOverviewResponse is the full body of GET /admin/api/overview (spec
+// §4, v0.2). Every slice is sorted and built fresh per request — no
+// caching of the response itself — so the dashboard's 5s poll always
+// reflects current state.
+type adminOverviewResponse struct {
+ Version string `json:"version"`
+ Providers []adminProviderView `json:"providers"`
+ Groups []adminGroupView `json:"groups"`
+ Redis adminRedisView `json:"redis"`
+ Cache adminCacheView `json:"cache"`
+}
+
+// buildAdminOverview assembles adminOverviewResponse from the registry,
+// limiter, cache, and authStore read hooks — nothing here mutates any of
+// them.
+func (g *Gateway) buildAdminOverview() adminOverviewResponse {
+ snaps := g.registry.snapshot()
+ providers := make([]adminProviderView, len(snaps))
+ for i, s := range snaps {
+ providers[i] = adminProviderView{
+ Name: s.name,
+ Type: s.typeName,
+ BaseURL: s.baseURL,
+ ModelCount: s.modelCount,
+ LastRefresh: s.lastRefresh,
+ LastErr: s.lastErr,
+ }
+ }
+
+ configured, redisLastErr := g.limiter.redisStatus()
+
+ cacheEnabled := g.cache != nil
+ var ttl string
+ if cacheEnabled {
+ ttl = g.cache.ttl.String()
+ }
+
+ _, groupSummaries := g.auth.snapshot()
+ groups := make([]adminGroupView, len(groupSummaries))
+ for i, gs := range groupSummaries {
+ groups[i] = adminGroupView{Name: gs.name, Limits: gs.limits, MemberCount: gs.memberCount}
+ }
+
+ return adminOverviewResponse{
+ Providers: providers,
+ Redis: adminRedisView{Configured: configured, LastErr: redisLastErr},
+ Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl},
+ Groups: groups,
+ Version: pluginVersion,
+ }
+}
+
+// serveAdminOverview writes buildAdminOverview's result as JSON.
+func (g *Gateway) serveAdminOverview(w http.ResponseWriter) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(g.buildAdminOverview())
+}
+
+// adminUsageEntryView is one user's or group's row in GET
+// /admin/api/usage (spec §4, v0.2): its current-window counter values,
+// with its configured limit echoed beside each. GroupName is set only for
+// a user entry (empty for a group entry) — the group a user currently
+// belongs to, for display; it carries no access-control meaning of its
+// own.
+type adminUsageEntryView struct {
+ Limits *LimitsConfig `json:"limits,omitempty"`
+ Kind string `json:"kind"`
+ ID string `json:"id"`
+ GroupName string `json:"groupName,omitempty"`
+ RequestsPerMinute int64 `json:"requestsPerMinute"`
+ RequestsPerDay int64 `json:"requestsPerDay"`
+ TokensPerDay int64 `json:"tokensPerDay"`
+ TokensPerMonth int64 `json:"tokensPerMonth"`
+ CostPerDayMicroUSD int64 `json:"costPerDayMicroUsd"`
+ CostPerMonthMicroUSD int64 `json:"costPerMonthMicroUsd"`
+ StoreDown bool `json:"storeDown,omitempty"`
+}
+
+// adminUsageResponse is the full body of GET /admin/api/usage: every
+// currently active user, and every configured group, each with its own
+// usage+limits row. Both slices are sorted (authStore.snapshot's own
+// contract) and built fresh per request.
+type adminUsageResponse struct {
+ Users []adminUsageEntryView `json:"users"`
+ Groups []adminUsageEntryView `json:"groups"`
+}
+
+// usageEntryView converts one limiter.currentUsage result plus its
+// matching limit into the JSON view.
+func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView {
+ return adminUsageEntryView{
+ Kind: su.kind,
+ ID: su.id,
+ Limits: limits,
+ RequestsPerMinute: su.requestsPerMinute,
+ RequestsPerDay: su.requestsPerDay,
+ TokensPerDay: su.tokensPerDay,
+ TokensPerMonth: su.tokensPerMonth,
+ CostPerDayMicroUSD: su.costPerDayMicros,
+ CostPerMonthMicroUSD: su.costPerMonthMicros,
+ StoreDown: su.storeDown,
+ }
+}
+
+// buildAdminUsage assembles adminUsageResponse: authStore.snapshot lists
+// every currently active user and every configured group (names, group
+// membership, and limits only — never a key or its digest), and
+// limiter.currentUsage reads each one's six current-window counters in
+// one read-only pass per list. Unlike buildLimitScopes (routes_
+// unified.go), this never omits an entity for having nil limits — the
+// dashboard shows usage for every user and group, limited or not.
+func (g *Gateway) buildAdminUsage() adminUsageResponse {
+ userSummaries, groupSummaries := g.auth.snapshot()
+
+ userScopes := make([]limitScope, len(userSummaries))
+ for i, us := range userSummaries {
+ userScopes[i] = limitScope{kind: "user", id: us.name, limits: us.limits}
+ }
+ groupScopes := make([]limitScope, len(groupSummaries))
+ for i, gs := range groupSummaries {
+ groupScopes[i] = limitScope{kind: "group", id: gs.name, limits: gs.limits}
+ }
+
+ userUsage := g.limiter.currentUsage(userScopes)
+ groupUsage := g.limiter.currentUsage(groupScopes)
+
+ users := make([]adminUsageEntryView, len(userUsage))
+ for i, su := range userUsage {
+ users[i] = usageEntryView(su, userSummaries[i].limits)
+ users[i].GroupName = userSummaries[i].groupName
+ }
+ groups := make([]adminUsageEntryView, len(groupUsage))
+ for i, su := range groupUsage {
+ groups[i] = usageEntryView(su, groupSummaries[i].limits)
+ }
+
+ return adminUsageResponse{Users: users, Groups: groups}
+}
+
+// serveAdminUsage writes buildAdminUsage's result as JSON.
+func (g *Gateway) serveAdminUsage(w http.ResponseWriter) {
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(g.buildAdminUsage())
+}
+
+// adminPageHTML is the entire /admin single-page dashboard: markup, CSS,
+// and vanilla JS in one Go raw-string const. Yaegi interpretation forbids
+// the embed directive (no filesystem access from an interpreted plugin),
+// so the page ships as source, exactly like every other Yaegi-compatible plugin's
+// static assets. It polls adminOverviewPath and adminUsagePath every 5s
+// via fetch, loads no external asset of any kind (matching adminCSP's
+// default-src 'none'), and never uses innerHTML with server-provided
+// strings — every dynamic value is written via textContent, so nothing
+// the API returns is ever interpreted as markup.
+const adminPageHTML = `
+
+
+
+
+LLM Gateway - Admin
+
+
+
+LLM Gateway - Admin
+loading...
+
+
+ Providers
+
+ | Name | Type | Base URL | Models | Last refresh | Last error |
+
+
+
+
+
+
+ Groups
+
+ | Name | Members | Limits | req/min | req/day | tok/day | tok/month | cost/day | cost/month |
+
+
+
+
+ Users
+
+ | Name | Group | Limits | req/min | req/day | tok/day | tok/month | cost/day | cost/month |
+
+
+
+
+
+
+`
diff --git a/admin_test.go b/admin_test.go
new file mode 100644
index 0000000..77fed57
--- /dev/null
+++ b/admin_test.go
@@ -0,0 +1,489 @@
+package traefikllmgateway
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// newAdminTestConfig builds a Config with two providers ("zeta", "alpha" —
+// deliberately out of sorted order, to prove snapshot sorting), two
+// groups ("zgroup", "agroup", same reason), a non-admin user in one group
+// and an admin user in the other, and Admin.Enabled true. Callers may
+// mutate the returned Config before calling New.
+func newAdminTestConfig() *Config {
+ cfg := CreateConfig()
+ cfg.Admin = &AdminConfig{Enabled: true}
+ cfg.Providers = map[string]*ProviderConfig{
+ "zeta": {Type: "openai", BaseURL: "http://zeta.invalid", APIKey: "sk-zeta", Models: []string{"z-model"}},
+ "alpha": {Type: "openai", BaseURL: "http://alpha.invalid", APIKey: "sk-alpha", Models: []string{"a-model-1", "a-model-2"}},
+ }
+ cfg.Groups = map[string]*GroupConfig{
+ "zgroup": {Limits: &LimitsConfig{RequestsPerDay: 500}},
+ "agroup": {},
+ }
+ cfg.Users = &UsersConfig{Inline: []*UserConfig{
+ {Name: "alice", Group: "agroup", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerDay: 100}},
+ {Name: "admin1", Group: "zgroup", APIKey: "sk-admin1", Admin: true},
+ }}
+ return cfg
+}
+
+// newAdminGatewayHandle builds the Gateway from cfg and returns both the
+// http.Handler ServeHTTP uses and the concrete *Gateway for tests that
+// need to reach into its internals (limiter, registry, auth).
+func newAdminGatewayHandle(t *testing.T, cfg *Config) (http.Handler, *Gateway) {
+ t.Helper()
+ next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) })
+ h, err := New(context.Background(), next, cfg, "llmgw")
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ gw, ok := h.(*Gateway)
+ if !ok {
+ t.Fatal("handler is not *Gateway")
+ }
+ return h, gw
+}
+
+func adminRequest(method, path, apiKey string) *http.Request {
+ req := httptest.NewRequest(method, path, nil)
+ if apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+apiKey)
+ }
+ return req
+}
+
+// --- gate: disabled/unauth/non-admin/admin (spec §4) ---
+
+func TestAdmin_Disabled_FallsThroughTo404(t *testing.T) {
+ t.Parallel()
+ cfg := newAdminTestConfig()
+ cfg.Admin = nil // not registered at all
+ h, _ := newAdminGatewayHandle(t, cfg)
+
+ for _, p := range []string{adminPagePath, adminOverviewPath, adminUsagePath} {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, p, ""))
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("path %q: status = %d, want 404 (admin nil)", p, rec.Code)
+ }
+ }
+}
+
+func TestAdmin_DisabledExplicitly_FallsThroughTo404(t *testing.T) {
+ t.Parallel()
+ cfg := newAdminTestConfig()
+ cfg.Admin = &AdminConfig{Enabled: false}
+ h, _ := newAdminGatewayHandle(t, cfg)
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPagePath, "sk-admin1"))
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404 (admin disabled, even with a valid admin key)", rec.Code)
+ }
+}
+
+func TestAdmin_Disabled_HonorsPassthroughUnknown(t *testing.T) {
+ t.Parallel()
+ cfg := newAdminTestConfig()
+ cfg.Admin = nil
+ cfg.PassthroughUnknown = true
+ h, _ := newAdminGatewayHandle(t, cfg)
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPagePath, ""))
+ // The stub "next" handler (newAdminGatewayHandle) answers 418 — proves
+ // /admin got no special-casing at all when disabled, it fell through
+ // exactly like any other unregistered path.
+ if rec.Code != http.StatusTeapot {
+ t.Fatalf("status = %d, want 418 from next handler (passthroughUnknown)", rec.Code)
+ }
+}
+
+func TestAdmin_GateMatrix(t *testing.T) {
+ cfg := newAdminTestConfig()
+ h, _ := newAdminGatewayHandle(t, cfg)
+
+ paths := []string{adminPagePath, adminOverviewPath, adminUsagePath}
+ for _, p := range paths {
+ t.Run(p+"/unauthenticated", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, p, ""))
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", rec.Code)
+ }
+ assertOAIErrorEnvelope(t, rec)
+ })
+ t.Run(p+"/authenticated_non_admin", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, p, "sk-alice"))
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403", rec.Code)
+ }
+ assertOAIErrorEnvelope(t, rec)
+ })
+ t.Run(p+"/admin", func(t *testing.T) {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, p, "sk-admin1"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
+ }
+ })
+ }
+}
+
+// assertOAIErrorEnvelope checks rec's body decodes as writeOAIError's
+// envelope shape.
+func assertOAIErrorEnvelope(t *testing.T, rec *httptest.ResponseRecorder) {
+ t.Helper()
+ var body struct {
+ Error struct {
+ Message string `json:"message"`
+ Type string `json:"type"`
+ } `json:"error"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode error envelope: %v (body=%s)", err, rec.Body.String())
+ }
+ if body.Error.Message == "" {
+ t.Error("error envelope has empty message")
+ }
+}
+
+// --- overview: shape, sorting, provider snapshot correctness ---
+
+func TestAdminOverview_SortedShapeAndVersion(t *testing.T) {
+ t.Parallel()
+ cfg := newAdminTestConfig()
+ h, _ := newAdminGatewayHandle(t, cfg)
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
+ }
+
+ var got adminOverviewResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ if got.Version != pluginVersion {
+ t.Errorf("version = %q, want %q", got.Version, pluginVersion)
+ }
+
+ if len(got.Providers) != 2 || got.Providers[0].Name != "alpha" || got.Providers[1].Name != "zeta" {
+ t.Fatalf("providers not sorted by name: %+v", got.Providers)
+ }
+ if got.Providers[0].Type != "openai" || got.Providers[0].BaseURL != "http://alpha.invalid" {
+ t.Errorf("alpha provider view = %+v, want type openai baseUrl http://alpha.invalid", got.Providers[0])
+ }
+ if got.Providers[0].ModelCount != 2 {
+ t.Errorf("alpha modelCount = %d, want 2 (a-model-1, a-model-2)", got.Providers[0].ModelCount)
+ }
+ if got.Providers[1].ModelCount != 1 {
+ t.Errorf("zeta modelCount = %d, want 1 (z-model)", got.Providers[1].ModelCount)
+ }
+ if got.Providers[0].LastErr != "" || got.Providers[1].LastErr != "" {
+ t.Errorf("providers with discovery disabled must have empty lastErr, got %+v", got.Providers)
+ }
+
+ if len(got.Groups) != 2 || got.Groups[0].Name != "agroup" || got.Groups[1].Name != "zgroup" {
+ t.Fatalf("groups not sorted by name: %+v", got.Groups)
+ }
+ if got.Groups[1].MemberCount != 1 { // admin1
+ t.Errorf("zgroup memberCount = %d, want 1", got.Groups[1].MemberCount)
+ }
+ if got.Groups[0].MemberCount != 1 { // alice
+ t.Errorf("agroup memberCount = %d, want 1", got.Groups[0].MemberCount)
+ }
+ if got.Groups[1].Limits == nil || got.Groups[1].Limits.RequestsPerDay != 500 {
+ t.Errorf("zgroup limits = %+v, want RequestsPerDay 500", got.Groups[1].Limits)
+ }
+
+ if got.Cache.Enabled {
+ t.Error("cache.enabled must be false: cfg.Cache was never configured")
+ }
+ if got.Redis.Configured {
+ t.Error("redis.configured must be false: cfg.Redis was never configured")
+ }
+}
+
+func TestAdminOverview_ProviderLastErrAfterFailedRefresh(t *testing.T) {
+ t.Parallel()
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ }))
+ defer srv.Close()
+
+ cfg := CreateConfig()
+ cfg.Admin = &AdminConfig{Enabled: true}
+ cfg.Providers = map[string]*ProviderConfig{
+ "broken": {Type: "openai", BaseURL: srv.URL, APIKey: "sk", Discovery: true},
+ }
+ cfg.Groups = map[string]*GroupConfig{"g": {}}
+ cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "admin1", Group: "g", APIKey: "sk-admin1", Admin: true}}}
+
+ // newGateway's warmFill runs the first discovery fetch synchronously
+ // during New, so the failure is already recorded before ServeHTTP is
+ // ever called.
+ h, _ := newAdminGatewayHandle(t, cfg)
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
+ }
+ var got adminOverviewResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(got.Providers) != 1 {
+ t.Fatalf("providers = %+v, want 1 entry", got.Providers)
+ }
+ p := got.Providers[0]
+ if p.LastErr == "" {
+ t.Error("lastErr must be non-empty after a failed discovery refresh")
+ }
+ if p.ModelCount != 0 {
+ t.Errorf("modelCount = %d, want 0 (no explicit models, discovery failed)", p.ModelCount)
+ }
+ if p.LastRefresh.IsZero() {
+ t.Error("lastRefresh must be set even on a failed refresh (finishRefresh always advances it)")
+ }
+}
+
+// --- usage: math against seeded counters ---
+
+func TestAdminUsage_MathAgainstSeededCounters(t *testing.T) {
+ t.Parallel()
+ cfg := newAdminTestConfig()
+ h, gw := newAdminGatewayHandle(t, cfg)
+
+ fixedNow := time.Date(2026, 8, 20, 12, 30, 0, 0, time.UTC)
+ gw.limiter.nowFn = func() time.Time { return fixedNow }
+
+ // Seed alice's (user) and agroup's (group) counters directly, bypassing
+ // checkAndCount/account's own request-shaped call pattern so the
+ // seeded values are exact and independent of admin1's own traffic.
+ gw.limiter.incrCounter("user", "alice", metricReq, windowMin, fixedNow, 3, minWindowTTL)
+ gw.limiter.incrCounter("user", "alice", metricReq, windowDay, fixedNow, 7, dayWindowTTL)
+ gw.limiter.account([]limitScope{{kind: "user", id: "alice"}}, usage{prompt: 40, completion: 10}, 2_500_000)
+
+ gw.limiter.incrCounter("group", "agroup", metricReq, windowMin, fixedNow, 5, minWindowTTL)
+ gw.limiter.incrCounter("group", "agroup", metricReq, windowDay, fixedNow, 9, dayWindowTTL)
+ gw.limiter.account([]limitScope{{kind: "group", id: "agroup"}}, usage{prompt: 100, completion: 50}, 9_000_000)
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsagePath, "sk-admin1"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String())
+ }
+
+ var got adminUsageResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ alice := findUsageEntry(t, got.Users, "alice")
+ if alice.RequestsPerMinute != 3 || alice.RequestsPerDay != 7 {
+ t.Errorf("alice requests = min:%d day:%d, want min:3 day:7", alice.RequestsPerMinute, alice.RequestsPerDay)
+ }
+ if alice.TokensPerDay != 50 || alice.TokensPerMonth != 50 {
+ t.Errorf("alice tokens = day:%d month:%d, want 50/50", alice.TokensPerDay, alice.TokensPerMonth)
+ }
+ if alice.CostPerDayMicroUSD != 2_500_000 || alice.CostPerMonthMicroUSD != 2_500_000 {
+ t.Errorf("alice cost = day:%d month:%d, want 2500000/2500000", alice.CostPerDayMicroUSD, alice.CostPerMonthMicroUSD)
+ }
+ if alice.Limits == nil || alice.Limits.RequestsPerDay != 100 {
+ t.Errorf("alice limits = %+v, want RequestsPerDay 100 echoed", alice.Limits)
+ }
+ if alice.GroupName != "agroup" {
+ t.Errorf("alice groupName = %q, want agroup", alice.GroupName)
+ }
+ if alice.StoreDown {
+ t.Error("alice.storeDown must be false")
+ }
+
+ agroup := findUsageEntry(t, got.Groups, "agroup")
+ if agroup.RequestsPerMinute != 5 || agroup.RequestsPerDay != 9 {
+ t.Errorf("agroup requests = min:%d day:%d, want min:5 day:9", agroup.RequestsPerMinute, agroup.RequestsPerDay)
+ }
+ if agroup.TokensPerDay != 150 || agroup.TokensPerMonth != 150 {
+ t.Errorf("agroup tokens = day:%d month:%d, want 150/150", agroup.TokensPerDay, agroup.TokensPerMonth)
+ }
+ if agroup.CostPerDayMicroUSD != 9_000_000 {
+ t.Errorf("agroup cost/day = %d, want 9000000", agroup.CostPerDayMicroUSD)
+ }
+
+ // admin1 itself must also be listed (buildAdminUsage lists every
+ // active user/group, not just ones with seeded traffic): admin1 has
+ // no user-level limits configured, so buildLimitScopes never builds a
+ // "user"/admin1 scope for its own requests (ruling e) and its own
+ // counters stay at zero — but the row must still be present.
+ admin1 := findUsageEntry(t, got.Users, "admin1")
+ if admin1.RequestsPerMinute != 0 {
+ t.Errorf("admin1 requestsPerMinute = %d, want 0 (admin1 has no user-level limits, so its own requests never build a user scope)", admin1.RequestsPerMinute)
+ }
+ if admin1.StoreDown {
+ t.Error("admin1.storeDown must be false")
+ }
+}
+
+func findUsageEntry(t *testing.T, entries []adminUsageEntryView, id string) adminUsageEntryView {
+ t.Helper()
+ for _, e := range entries {
+ if e.ID == id {
+ return e
+ }
+ }
+ t.Fatalf("no usage entry for id %q in %+v", id, entries)
+ return adminUsageEntryView{}
+}
+
+// TestLimiterCurrentUsage_StoreDown drives limiter.currentUsage's
+// fail-closed path directly: a configured store whose every operation
+// errors, with failOpen false, must report storeDown and zero values
+// rather than a silently wrong reading. alwaysErrStore/errStoreDownStub
+// are defined in routes_unified_test.go.
+func TestLimiterCurrentUsage_StoreDown(t *testing.T) {
+ t.Parallel()
+ l := newLimiter(alwaysErrStore{}, false)
+ scopes := []limitScope{{kind: "user", id: "x", limits: &LimitsConfig{RequestsPerMinute: 10}}}
+
+ got := l.currentUsage(scopes)
+ if len(got) != 1 {
+ t.Fatalf("len(got) = %d, want 1", len(got))
+ }
+ su := got[0]
+ if !su.storeDown {
+ t.Fatal("want storeDown true when the configured store errors and failOpen is false")
+ }
+ if su.requestsPerMinute != 0 || su.requestsPerDay != 0 || su.tokensPerDay != 0 ||
+ su.tokensPerMonth != 0 || su.costPerDayMicros != 0 || su.costPerMonthMicros != 0 {
+ t.Errorf("storeDown entry must report zero values, got %+v", su)
+ }
+}
+
+// --- rate limit applies to admin routes ---
+
+func TestAdmin_RateLimitApplies(t *testing.T) {
+ t.Parallel()
+ cfg := newAdminTestConfig()
+ cfg.Users.Inline[1].Limits = &LimitsConfig{RequestsPerMinute: 1} // admin1
+ h, gw := newAdminGatewayHandle(t, cfg)
+
+ fixedNow := time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC)
+ gw.limiter.nowFn = func() time.Time { return fixedNow }
+
+ rec1 := httptest.NewRecorder()
+ h.ServeHTTP(rec1, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
+ if rec1.Code != http.StatusOK {
+ t.Fatalf("1st request status = %d, want 200, body=%s", rec1.Code, rec1.Body.String())
+ }
+
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, adminRequest(http.MethodGet, adminOverviewPath, "sk-admin1"))
+ if rec2.Code != http.StatusTooManyRequests {
+ t.Fatalf("2nd request status = %d, want 429 (admin over req/min limit)", rec2.Code)
+ }
+ if rec2.Header().Get("Retry-After") == "" {
+ t.Error("429 response must carry a Retry-After header")
+ }
+}
+
+// --- HTML page: CSP header + fetch URLs, no external assets ---
+
+func TestAdminPage_CSPHeaderAndFetchURLs(t *testing.T) {
+ t.Parallel()
+ cfg := newAdminTestConfig()
+ h, _ := newAdminGatewayHandle(t, cfg)
+
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPagePath, "sk-admin1"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "text/html") {
+ t.Errorf("content-type = %q, want text/html", ct)
+ }
+ wantCSP := "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'"
+ if got := rec.Header().Get("Content-Security-Policy"); got != wantCSP {
+ t.Errorf("CSP = %q, want %q", got, wantCSP)
+ }
+
+ body := rec.Body.String()
+ for _, want := range []string{adminOverviewPath, adminUsagePath, "setInterval"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("admin page body missing %q", want)
+ }
+ }
+ if strings.Contains(body, "http://") || strings.Contains(body, "https://") {
+ t.Error("admin page must load zero external assets (no http(s):// reference of any kind)")
+ }
+}
+
+// --- redaction: no secret literal, "apiKey", or key digest ever appears ---
+
+func TestAdmin_SecretRedaction(t *testing.T) {
+ t.Parallel()
+ const (
+ providerKey = "SECRET-CANARY-PROVIDERKEY"
+ redisPass = "SECRET-CANARY-REDISPW"
+ aliceKey = "SECRET-CANARY-USERKEY"
+ adminKey = "SECRET-CANARY-ADMINKEY"
+ )
+
+ cfg := CreateConfig()
+ cfg.Admin = &AdminConfig{Enabled: true}
+ cfg.Providers = map[string]*ProviderConfig{
+ "openai": {Type: "openai", BaseURL: "http://openai.invalid", APIKey: providerKey, Models: []string{"gpt-test"}},
+ }
+ cfg.Redis = &RedisConfig{Address: "127.0.0.1:1", Password: redisPass}
+ cfg.Groups = map[string]*GroupConfig{"g": {}}
+ cfg.Users = &UsersConfig{Inline: []*UserConfig{
+ {Name: "alice", Group: "g", APIKey: aliceKey, Limits: &LimitsConfig{}},
+ {Name: "admin1", Group: "g", APIKey: adminKey, Admin: true},
+ }}
+
+ h, _ := newAdminGatewayHandle(t, cfg)
+
+ overviewRec := httptest.NewRecorder()
+ h.ServeHTTP(overviewRec, adminRequest(http.MethodGet, adminOverviewPath, adminKey))
+ if overviewRec.Code != http.StatusOK {
+ t.Fatalf("overview status = %d, want 200, body=%s", overviewRec.Code, overviewRec.Body.String())
+ }
+
+ usageRec := httptest.NewRecorder()
+ h.ServeHTTP(usageRec, adminRequest(http.MethodGet, adminUsagePath, adminKey))
+ if usageRec.Code != http.StatusOK {
+ t.Fatalf("usage status = %d, want 200, body=%s", usageRec.Code, usageRec.Body.String())
+ }
+
+ canaries := []string{providerKey, redisPass, aliceKey, adminKey}
+ for _, name := range []string{"overview", "usage"} {
+ body := overviewRec.Body.String()
+ if name == "usage" {
+ body = usageRec.Body.String()
+ }
+ for _, secret := range canaries {
+ if strings.Contains(body, secret) {
+ t.Errorf("%s response leaks secret literal %q", name, secret)
+ }
+ digest := sha256.Sum256([]byte(secret))
+ if strings.Contains(body, hex.EncodeToString(digest[:])) {
+ t.Errorf("%s response leaks sha256 digest of secret %q", name, secret)
+ }
+ }
+ if strings.Contains(strings.ToLower(body), "apikey") {
+ t.Errorf("%s response contains an apiKey field", name)
+ }
+ }
+}
diff --git a/auth.go b/auth.go
index 568ca1c..d2fe148 100644
--- a/auth.go
+++ b/auth.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"path"
+ "sort"
"strings"
"sync"
"time"
@@ -15,6 +16,10 @@ import (
type user struct {
limits *LimitsConfig
name, groupName string
+ // admin grants access to the read-only admin dashboard (spec §4,
+ // v0.2) — an admin user is otherwise ordinary: their own limits and
+ // group authorization still apply, including to the admin routes.
+ admin bool
}
// group describes a group's access rules and default limits. Empty
@@ -189,7 +194,7 @@ func (a *authStore) buildEntry(uc *UserConfig) (*authEntry, error) {
digest := sha256.Sum256([]byte(key))
return &authEntry{
digest: digest,
- user: &user{limits: uc.Limits, name: uc.Name, groupName: uc.Group},
+ user: &user{limits: uc.Limits, name: uc.Name, groupName: uc.Group, admin: uc.Admin},
group: grp,
}, nil
}
@@ -287,3 +292,49 @@ func presentedKey(r *http.Request) (string, bool) {
}
return "", false
}
+
+// userSummary and groupSummary are the read-only, redaction-safe listing
+// authStore.snapshot returns for the admin dashboard (spec §4, v0.2):
+// names, group membership, and limits only — never an API key or its
+// digest, matching the package-wide "raw keys are never retained past
+// construction" rule this file's own doc comment already states for
+// byDigest itself.
+type userSummary struct {
+ limits *LimitsConfig
+ name string
+ groupName string
+}
+
+// groupSummary mirrors userSummary for one configured group, plus its
+// current member count (how many active users, inline or file-sourced,
+// currently reference it).
+type groupSummary struct {
+ limits *LimitsConfig
+ name string
+ memberCount int
+}
+
+// snapshot returns a sorted, redaction-safe listing of every currently
+// active user and every configured group. a.mu is read-locked only for
+// the byDigest walk, mirroring identify's own RLock usage; a.groups is
+// built once by newAuthStore and never mutated afterward (see this
+// file's authStore doc comment), so reading it needs no lock.
+func (a *authStore) snapshot() ([]userSummary, []groupSummary) {
+ a.mu.RLock()
+ users := make([]userSummary, 0, len(a.byDigest))
+ memberCounts := make(map[string]int, len(a.groups))
+ for _, entry := range a.byDigest {
+ users = append(users, userSummary{limits: entry.user.limits, name: entry.user.name, groupName: entry.user.groupName})
+ memberCounts[entry.user.groupName]++
+ }
+ a.mu.RUnlock()
+ sort.Slice(users, func(i, j int) bool { return users[i].name < users[j].name })
+
+ groups := make([]groupSummary, 0, len(a.groups))
+ for name, grp := range a.groups {
+ groups = append(groups, groupSummary{limits: grp.limits, name: name, memberCount: memberCounts[name]})
+ }
+ sort.Slice(groups, func(i, j int) bool { return groups[i].name < groups[j].name })
+
+ return users, groups
+}
diff --git a/limits.go b/limits.go
index 00338b1..c9f2c1a 100644
--- a/limits.go
+++ b/limits.go
@@ -266,8 +266,15 @@ type limiter struct {
logf func(format string, args ...any)
lastLogAt time.Time // guarded by logMu; last time a store error was logged
lastStoreFailure time.Time // guarded by logMu; zero means the store-down latch is not open (see storeLatched)
- logMu sync.Mutex
- failOpen bool // store-error policy: true falls back to fallback, false refuses the request
+ // lastErrMsg is the message of the most recent store operation
+ // failure, guarded by logMu alongside lastStoreFailure. It is never
+ // cleared on a later success — "last store error" for the admin
+ // dashboard (spec §4, v0.2) means exactly that, a persisting fact,
+ // not "is the store currently failing" (storeLatched already answers
+ // that question for the enforcement path).
+ lastErrMsg string
+ logMu sync.Mutex
+ failOpen bool // store-error policy: true falls back to fallback, false refuses the request
}
// newLimiter returns a limiter. A nil store means every operation uses the
@@ -328,9 +335,21 @@ func (l *limiter) recordStoreFailure(err error) {
l.logStoreError(err)
l.logMu.Lock()
l.lastStoreFailure = l.now()
+ l.lastErrMsg = err.Error()
l.logMu.Unlock()
}
+// redisStatus reports whether l has a configured (non-fallback-only)
+// store, and that store's most recent operation failure message — the
+// admin dashboard's redis status line (spec §4, v0.2). lastErr is "" when
+// no store operation has ever failed. Guarded by logMu, the same mutex
+// lastStoreFailure/lastErrMsg already use.
+func (l *limiter) redisStatus() (configured bool, lastErr string) {
+ l.logMu.Lock()
+ defer l.logMu.Unlock()
+ return l.store != nil, l.lastErrMsg
+}
+
// storeIncrBy increments key by n with the given ttl, applying the
// limiter's fail-open/fail-closed policy when a configured store errors
// or when the store-down latch (storeLatched) is already open from a
@@ -602,3 +621,54 @@ func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) {
}
}
}
+
+// scopeUsage is one scope's (a user's or a group's) current-window
+// counter values — the admin dashboard's usage table (spec §4, v0.2). The
+// six value fields mirror LimitsConfig's six limit kinds exactly, so the
+// admin API can echo a usage value beside its matching limit.
+type scopeUsage struct {
+ kind string // "user" or "group", mirroring limitScope.kind
+ id string
+ requestsPerMinute int64
+ requestsPerDay int64
+ tokensPerDay int64
+ tokensPerMonth int64
+ costPerDayMicros int64
+ costPerMonthMicros int64
+ // storeDown reports whether reading any of the six counters above
+ // failed closed (a configured store errored and failOpen is false) —
+ // mirrors limitViolation.storeDown. Every value field is 0 in that
+ // case; a caller must not present them as "confirmed zero usage".
+ storeDown bool
+}
+
+// currentUsage reads every scope's six current-window counters — the
+// same (kind, id, metric, window) combinations checkAndCount/account
+// already write — via the limiter's own getCounter, so it applies the
+// identical fail-open/fail-closed policy every enforcement read already
+// does. It is read-only: unlike checkAndCount, it never increments
+// anything. Order is preserved: currentUsage(scopes)[i] corresponds to
+// scopes[i].
+func (l *limiter) currentUsage(scopes []limitScope) []scopeUsage {
+ now := l.now()
+ out := make([]scopeUsage, len(scopes))
+ for i, sc := range scopes {
+ reqMin, ok1 := l.getCounter(sc.kind, sc.id, metricReq, windowMin, now)
+ reqDay, ok2 := l.getCounter(sc.kind, sc.id, metricReq, windowDay, now)
+ tokDay, ok3 := l.getCounter(sc.kind, sc.id, metricTok, windowDay, now)
+ tokMonth, ok4 := l.getCounter(sc.kind, sc.id, metricTok, windowMonth, now)
+ costDay, ok5 := l.getCounter(sc.kind, sc.id, metricCost, windowDay, now)
+ costMonth, ok6 := l.getCounter(sc.kind, sc.id, metricCost, windowMonth, now)
+ if !ok1 || !ok2 || !ok3 || !ok4 || !ok5 || !ok6 {
+ out[i] = scopeUsage{kind: sc.kind, id: sc.id, storeDown: true}
+ continue
+ }
+ out[i] = scopeUsage{
+ kind: sc.kind, id: sc.id,
+ requestsPerMinute: reqMin, requestsPerDay: reqDay,
+ tokensPerDay: tokDay, tokensPerMonth: tokMonth,
+ costPerDayMicros: costDay, costPerMonthMicros: costMonth,
+ }
+ }
+ return out
+}
diff --git a/llmgateway.go b/llmgateway.go
index eac05a0..1a19f8a 100644
--- a/llmgateway.go
+++ b/llmgateway.go
@@ -22,6 +22,11 @@ type Config struct {
Agents map[string]*AgentConfig `json:"agents,omitempty"`
Users *UsersConfig `json:"users,omitempty"`
Redis *RedisConfig `json:"redis,omitempty"`
+ // Admin gates the read-only admin dashboard (spec §4, v0.2): nil or
+ // Admin.Enabled false means the /admin* routes are not registered at
+ // all — ServeHTTP falls through to its existing 404/passthroughUnknown
+ // handling for those paths, preserving v0.1 behavior exactly.
+ Admin *AdminConfig `json:"admin,omitempty"`
// Retry is a struct value, not a pointer, because its own Enabled
// field is the on/off signal (unlike Redis/Users, where the block's
// mere presence is the signal) — so its tag omits "omitempty":
@@ -84,6 +89,13 @@ type UserConfig struct {
Name string `json:"name"`
Group string `json:"group"`
APIKey string `json:"apiKey"`
+ // Admin grants this user access to the read-only admin dashboard
+ // (spec §4, v0.2), whether the user is inline or file-sourced —
+ // file-users granting admin is operator-controlled via the Secret
+ // backing Users.File, which is an accepted trust boundary. An admin
+ // user is otherwise an ordinary user: their own keys, group, and
+ // limits still apply, including to the admin routes themselves.
+ Admin bool `json:"admin,omitempty"`
}
// RedisConfig configures the distributed limit-state backend.
@@ -136,6 +148,15 @@ type CacheConfig struct {
Enabled bool `json:"enabled,omitempty"`
}
+// AdminConfig configures the read-only admin dashboard (spec §4, v0.2).
+// The zero value (Enabled: false) disables it, preserving v0.1 behavior
+// exactly: no /admin* route is registered, so those paths fall through
+// to ServeHTTP's existing 404/passthroughUnknown handling like any other
+// unrecognized path.
+type AdminConfig struct {
+ Enabled bool `json:"enabled,omitempty"`
+}
+
// ModelPricing overrides the built-in per-model price table.
type ModelPricing struct {
InputPerM float64 `json:"inputPerM"`
@@ -383,6 +404,16 @@ func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}
+ // Admin routes (spec §4, v0.2) are matched only when adminEnabled: a
+ // nil or disabled Config.Admin means none of these paths are
+ // registered at all, so a request to /admin* falls through to the
+ // same 404/passthroughUnknown handling as any other unrecognized
+ // path — no special-casing needed for the disabled case.
+ if adminEnabled(g.cfg) && r.Method == http.MethodGet && isAdminPath(r.URL.Path) {
+ g.handleAdmin(sw, r)
+ return
+ }
+
if r.Method == http.MethodGet && (r.URL.Path == "/v1/mcp/servers" || r.URL.Path == "/v1/agents") {
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)
diff --git a/registry.go b/registry.go
index 1e72dee..21f232d 100644
--- a/registry.go
+++ b/registry.go
@@ -42,9 +42,17 @@ var errModelDenied = errors.New("llmgateway: model access denied")
// stale-while-error — a failed refresh leaves the previous discovered set
// in place rather than clearing it. mu guards every mutable field below it.
type providerState struct {
- lastRefresh time.Time
- explicit map[string]bool
- discovered map[string]bool
+ lastRefresh time.Time
+ explicit map[string]bool
+ discovered map[string]bool
+ // lastErr is the most recent finishRefresh call's error message, or
+ // "" when that call succeeded (or discovery is disabled and
+ // finishRefresh was never called at all). Read by snapshot for the
+ // admin dashboard (spec §4, v0.2) — it reflects the latest attempt's
+ // outcome, not the stale-while-error discovered set: a provider can
+ // show a non-empty lastErr while modelCount still reflects its last
+ // successful discovery.
+ lastErr string
interval time.Duration
mu sync.Mutex
discoveryEnabled bool
@@ -106,8 +114,10 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) {
st.inFlight = false
st.lastRefresh = now
if err != nil {
+ st.lastErr = err.Error()
return
}
+ st.lastErr = ""
discovered := make(map[string]bool, len(ids))
for _, id := range ids {
discovered[id] = true
@@ -115,6 +125,22 @@ func (st *providerState) finishRefresh(now time.Time, ids []string, err error) {
st.discovered = discovered
}
+// snapshot returns st's read-only view for the admin dashboard (spec §4,
+// v0.2): its known model count (explicit ∪ discovered, mirroring
+// knownIDs), last refresh time, and last refresh error message.
+func (st *providerState) snapshot() (modelCount int, lastRefresh time.Time, lastErr string) {
+ st.mu.Lock()
+ defer st.mu.Unlock()
+ set := make(map[string]bool, len(st.explicit)+len(st.discovered))
+ for id := range st.explicit {
+ set[id] = true
+ }
+ for id := range st.discovered {
+ set[id] = true
+ }
+ return len(set), st.lastRefresh, st.lastErr
+}
+
// modelRegistry aggregates every configured provider's explicit and
// discovered model ids into the gateway's model-resolution and /v1/models
// listing surfaces. adapters, providerNames, and states are all fixed at
@@ -443,3 +469,37 @@ func (m *modelRegistry) listFor(grp *group) []map[string]any {
func modelObject(id, ownedBy string) map[string]any {
return map[string]any{"id": id, "object": "model", "owned_by": ownedBy}
}
+
+// providerSnapshot is one provider's read-only view for the admin
+// dashboard (spec §4, v0.2). baseURL is not secret (the spec's NEVER-
+// exposed list is API keys, digests, redis password, and users-file path
+// contents only) — it is included so an operator can see which upstream
+// a provider actually targets.
+type providerSnapshot struct {
+ lastRefresh time.Time
+ name string
+ typeName string
+ baseURL string
+ lastErr string
+ modelCount int
+}
+
+// snapshot returns every configured provider's read-only view, sorted by
+// name (m.providerNames is already sorted at construction) — the admin
+// dashboard's provider table (spec §4, v0.2).
+func (m *modelRegistry) snapshot() []providerSnapshot {
+ out := make([]providerSnapshot, 0, len(m.providerNames))
+ for _, name := range m.providerNames {
+ adapter := m.adapters[name]
+ modelCount, lastRefresh, lastErr := m.states[name].snapshot()
+ out = append(out, providerSnapshot{
+ name: adapter.name(),
+ typeName: adapter.typeName(),
+ baseURL: adapter.base(),
+ modelCount: modelCount,
+ lastRefresh: lastRefresh,
+ lastErr: lastErr,
+ })
+ }
+ return out
+}