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

NameTypeBase URLModelsLast refreshLast error

Infrastructure

Groups

NameMembersLimitsreq/minreq/daytok/daytok/monthcost/daycost/month

Users

NameGroupLimitsreq/minreq/daytok/daytok/monthcost/daycost/month
`