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 |
`