package traefikllmgateway
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// The four routes the read-only admin dashboard registers (spec §4, v0.2;
// adminUsageHistoryPath added by the v0.2 data-layer task), matched only
// when adminEnabled(g.cfg) — see llmgateway.go's ServeHTTP dispatch.
const (
adminPagePath = "/admin"
adminOverviewPath = "/admin/api/overview"
adminUsagePath = "/admin/api/usage"
adminUsageHistoryPath = "/admin/api/usage/history"
)
// adminCSP is the Content-Security-Policy header served with all three
// admin routes (spec §4, v0.2; extended to the two JSON routes by a
// folded review item, 2026-08-20 review): 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. frame-ancestors/base-uri/
// form-action are all 'none' (v0.2 final review wave, 2026-08-20): the
// page carries a password-type key-entry input, so it must never be
// embeddable in another site's frame (clickjacking), never have its
// href hijacked to retarget a relative script/fetch URL, and never
// have its auth-form submitted anywhere but nowhere at all — the form's
// own submit handler already intercepts and cancels the real submit
// (adminPageHTML's "submit" listener calls preventDefault()), so
// form-action has nothing legitimate to allow.
const adminCSP = "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
// 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 four admin routes.
func isAdminPath(path string) bool {
return path == adminPagePath || path == adminOverviewPath || path == adminUsagePath || path == adminUsageHistoryPath
}
// handleAdmin is ServeHTTP's single entry point for all three /admin*
// routes (spec §4, v0.2), called only when adminEnabled — ServeHTTP's
// dispatch already checked that.
//
// GET /admin itself serves the HTML shell with no authentication at all
// (controller-approved amendment to spec §4, 2026-08-20 review): a
// browser navigating straight to a URL cannot attach a custom
// Authorization or x-api-key header, so the original "401 when
// unauthenticated" gate made the dashboard unreachable from a browser in
// the first place. The shell carries zero data — every value is fetched
// client-side from /admin/api/*, which stay fully gated below — so there
// is nothing to protect by gating the page itself. adminEnabled(g.cfg)
// still governs whether GET /admin is reachable at all (disabled falls
// through to 404, unchanged).
func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) {
if r.URL.Path == adminPagePath {
g.serveAdminPage(sw)
return
}
g.handleAdminAPI(sw, r)
}
// handleAdminAPI is the gate for the three /admin/api/* JSON routes,
// applying spec §4's gate order: unauthenticated → 401, authenticated
// non-admin → 403, admin → serve. GET /admin/api/overview and GET
// /admin/api/usage count request counters via checkAndCount before
// serving, like every other authenticated route: 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").
//
// GET /admin/api/usage/history (v0.2 data-layer task) is the one
// exception: it skips checkAndCount entirely. The operator-directed
// removal of admin-route stat counting (progress ledger, 2026-08-20)
// lands for every /admin/api/* route in a later task; this new route is
// built consistent with that direction from the start, rather than
// counted now and un-counted later.
func (g *Gateway) handleAdminAPI(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 r.URL.Path == adminUsageHistoryPath {
g.serveAdminUsageHistory(sw, r)
return
}
if violation := g.limiter.checkAndCount(buildLimitScopes(u, grp)); violation != nil {
writeLimitViolation(sw, violation)
return
}
switch r.URL.Path {
case adminOverviewPath:
g.serveAdminOverview(sw)
case adminUsagePath:
g.serveAdminUsage(sw)
}
}
// serveAdminPage writes the single-page dashboard (adminPageHTML, below)
// with its Content-Security-Policy header. Unauthenticated by design —
// see handleAdmin's doc comment.
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)
}
// setAdminJSONHeaders applies the response headers shared by both admin
// JSON endpoints (folded review item, 2026-08-20 review): the same CSP
// as the HTML page, X-Content-Type-Options: nosniff (a browser must
// never guess this response is anything other than the JSON it is
// declared as), and Cache-Control: no-store (every response is built
// fresh per request from live state and must never linger in a shared
// or disk cache).
func setAdminJSONHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Security-Policy", adminCSP)
}
// sanitizeBaseURL strips a provider baseUrl's userinfo (username and
// password) and query string before admin.go ever echoes it (folded
// review item, 2026-08-20 review): an operator who embeds credentials in
// a baseUrl — "https://token@host" or "https://host?api-key=..." are
// both misconfigurations, but ones this dashboard must never amplify
// into a credential leak — must never see them reflected back. This
// strips the userinfo entirely rather than only the password the way
// net/url's own URL.Redacted() does: Redacted() would still leave a bare
// embedded username in place, and a bare username with no separate
// password is itself commonly how a token gets embedded in a URL. A raw
// value that fails to parse as a URL is returned unchanged — it names no
// scheme/host/userinfo net/url can identify, so there is nothing
// structured left to strip.
func sanitizeBaseURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
}
u.User = nil
u.RawQuery = ""
u.Fragment = ""
return u.String()
}
// sanitizeProviderErr applies sanitizeBaseURL's userinfo/query-stripping
// treatment to lastErr before it is ever echoed by GET /admin/api/overview
// (v0.2 final review wave, 2026-08-20): a provider's discovery/refresh
// error commonly embeds the request URL it failed against (Go's
// net/http wraps a *url.Error carrying it verbatim), so a baseUrl
// misconfigured with embedded credentials would otherwise leak straight
// back out through this field, even though adminProviderView.BaseURL
// itself is already sanitized.
//
// This is best-effort, not a general URL scrubber: it looks only for
// rawBaseURL appearing verbatim inside lastErr and replaces every such
// occurrence with sanitizeBaseURL's cleaned form. An error naming some
// OTHER URL — a redirect target, a differently-formatted variant of the
// same host — is not caught; recognizing every URL shape a wrapped error
// might embed, without risking mangling ordinary error text, is not
// attempted here.
func sanitizeProviderErr(lastErr, rawBaseURL string) string {
if lastErr == "" || rawBaseURL == "" || !strings.Contains(lastErr, rawBaseURL) {
return lastErr
}
return strings.ReplaceAll(lastErr, rawBaseURL, sanitizeBaseURL(rawBaseURL))
}
// 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 by itself, and any credential-shaped
// userinfo/query an operator mistakenly embedded in it is stripped by
// sanitizeBaseURL before this view is ever built.
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.
// LastErrAt's json tag omits "omitempty": encoding/json never treats a
// struct value (time.Time) as "empty" regardless of its fields, so the
// tag would be a silent no-op — the same caveat Config's own Retry/Cache
// field doc comments already record (llmgateway.go). Its zero value
// ("0001-01-01T00:00:00Z") is the "no error recorded yet" sentinel,
// matching adminProviderView.LastRefresh's own convention; the dashboard
// renders it as "last error (Ns ago): ..." (folded review item,
// 2026-08-20 review) so a single transient blip does not read as
// currently-broken long after it recovered.
type adminRedisView struct {
LastErrAt time.Time `json:"lastErrAt"`
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"`
}
// adminRetryView is the retry config summary in GET /admin/api/overview
// (v0.2 final review wave, 2026-08-20): the EFFECTIVE enabled/attempts/
// backoff a `retry: {enabled: true}` block resolves to, after
// newRetryPolicy's own zero-value defaulting (retry.go) — not the raw,
// possibly-empty RetryConfig fields, so an operator sees what actually
// governs upstream calls, not just what they left unset. Attempts and
// Backoff are both omitted (zero value / empty string) when retry is
// disabled, mirroring adminCacheView's own omitempty convention for a
// disabled feature's now-meaningless detail fields.
type adminRetryView struct {
Backoff string `json:"backoff,omitempty"`
Enabled bool `json:"enabled"`
Attempts int `json:"attempts,omitempty"`
}
// 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"`
}
// adminAliasView is one configured model alias's row in GET
// /admin/api/overview (spec §5, v0.2): the alias->target pair exactly as
// an operator wrote it. No secrets involved.
type adminAliasView struct {
Alias string `json:"alias"`
Target string `json:"target"`
}
// 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"`
Aliases []adminAliasView `json:"aliases"`
Redis adminRedisView `json:"redis"`
Cache adminCacheView `json:"cache"`
Retry adminRetryView `json:"retry"`
}
// 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: sanitizeBaseURL(s.baseURL),
ModelCount: s.modelCount,
LastRefresh: s.lastRefresh,
LastErr: sanitizeProviderErr(s.lastErr, s.baseURL),
}
}
configured, redisLastErr, redisLastErrAt := g.limiter.redisStatus()
cacheEnabled := g.cache != nil
var ttl string
if cacheEnabled {
ttl = g.cache.ttl.String()
}
// newRetryPolicy is pure (no I/O) and already ran once, successfully,
// when this Gateway was constructed (providers.go's buildAdapters) —
// g.cfg.Retry cannot have changed since a running Gateway never
// mutates its own config, so this second call is guaranteed to return
// the identical result with a nil error; the error return is
// intentionally discarded rather than propagated.
rp, _ := newRetryPolicy(g.cfg.Retry)
retryView := adminRetryView{Enabled: rp.enabled}
if rp.enabled {
retryView.Attempts = rp.attempts
retryView.Backoff = rp.backoff.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}
}
aliasSnaps := g.registry.aliasSnapshot()
aliases := make([]adminAliasView, len(aliasSnaps))
for i, a := range aliasSnaps {
aliases[i] = adminAliasView(a) // identical underlying field shape (alias, target string), differing only in json tags
}
return adminOverviewResponse{
Providers: providers,
Redis: adminRedisView{Configured: configured, LastErr: redisLastErr, LastErrAt: redisLastErrAt},
Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl},
Retry: retryView,
Groups: groups,
Aliases: aliases,
Version: pluginVersion,
}
}
// serveAdminOverview writes buildAdminOverview's result as JSON.
func (g *Gateway) serveAdminOverview(w http.ResponseWriter) {
setAdminJSONHeaders(w)
_ = json.NewEncoder(w).Encode(g.buildAdminOverview())
}
// adminUsageEntryView is one user's, group's, or the total scope'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 or the total entry) — the
// group a user currently belongs to, for display; it carries no
// access-control meaning of its own. TokensInPerDay/TokensOutPerDay and
// their per-month counterparts replace a single combined
// tokensPerDay/tokensPerMonth pair (v0.2 data-layer task, tokens-in/
// tokens-out split) — a client wanting the combined total sums the two
// itself.
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"`
TokensInPerDay int64 `json:"tokensInPerDay"`
TokensOutPerDay int64 `json:"tokensOutPerDay"`
TokensInPerMonth int64 `json:"tokensInPerMonth"`
TokensOutPerMonth int64 `json:"tokensOutPerMonth"`
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, plus the synthetic total scope's own row (v0.2
// data-layer task) — usage summed across every user and group combined,
// limits always nil (see totalScopeKind). Users and Groups are both
// sorted (authStore.snapshot's own contract) and built fresh per request.
type adminUsageResponse struct {
Users []adminUsageEntryView `json:"users"`
Groups []adminUsageEntryView `json:"groups"`
Total adminUsageEntryView `json:"total"`
}
// 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,
TokensInPerDay: su.tokensInPerDay,
TokensOutPerDay: su.tokensOutPerDay,
TokensInPerMonth: su.tokensInPerMonth,
TokensOutPerMonth: su.tokensOutPerMonth,
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 every one's current-window counters — plus
// the synthetic total scope's own (v0.2 data-layer task) — in ONE
// storeGetMulti round trip total (v0.2 final review wave, 2026-08-20; see
// limiter.currentUsage's own doc comment for the tradeoff this
// supersedes) — users, groups, and the total scope are concatenated into
// a single scopes slice before that one call, then the flat result is
// sliced back into the three response sections at the same split points,
// order preserved. 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()
scopes := make([]limitScope, 0, len(userSummaries)+len(groupSummaries)+1)
for _, us := range userSummaries {
scopes = append(scopes, limitScope{kind: "user", id: us.name, limits: us.limits})
}
for _, gs := range groupSummaries {
scopes = append(scopes, limitScope{kind: "group", id: gs.name, limits: gs.limits})
}
scopes = append(scopes, limitScope{kind: totalScopeKind, id: totalScopeID, limits: nil})
allUsage := g.limiter.currentUsage(scopes)
userUsage := allUsage[:len(userSummaries)]
groupUsage := allUsage[len(userSummaries) : len(userSummaries)+len(groupSummaries)]
totalUsage := allUsage[len(userSummaries)+len(groupSummaries)]
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)
}
total := usageEntryView(totalUsage, nil)
return adminUsageResponse{Users: users, Groups: groups, Total: total}
}
// serveAdminUsage writes buildAdminUsage's result as JSON.
func (g *Gateway) serveAdminUsage(w http.ResponseWriter) {
setAdminJSONHeaders(w)
_ = json.NewEncoder(w).Encode(g.buildAdminUsage())
}
// historyMaxSpan caps GET /admin/api/usage/history's span query parameter
// per window (v0.2 data-layer task), matching each window's own retention
// (limits.go: hourWindowTTL/dayWindowTTL/monthWindowTTL) rounded down to
// whole buckets: 48 hourly buckets, 35 daily buckets, 13 monthly buckets
// (roughly a 4-day margin, not a full month: 13 average-length ~30.4-day
// months is ~396 days, against monthWindowTTL's 400*24h = 400 days —
// review sweep, 2026-08-21, corrects an earlier "one-month margin"
// overclaim here). A span beyond what its window's TTL could ever have
// kept alive would only echo zeros for the missing tail, so the cap keeps
// every requested bucket meaningful. window is assumed already validated
// by validHistoryWindow — the default case is a programming error, not a
// client input, mirroring windowKey/windowEnd's own panic convention.
func historyMaxSpan(window string) int {
switch window {
case windowHour:
return 48
case windowDay:
return 35
case windowMonth:
return 13
default:
panic(fmt.Sprintf("llmgateway: historyMaxSpan: unknown window %q", window))
}
}
// validHistoryMetric reports whether metric is one of GET
// /admin/api/usage/history's four accepted metric values.
func validHistoryMetric(metric string) bool {
switch metric {
case metricReq, metricTokIn, metricTokOut, metricCost:
return true
default:
return false
}
}
// validHistoryWindow reports whether window is one of GET
// /admin/api/usage/history's three accepted window values.
func validHistoryWindow(window string) bool {
switch window {
case windowHour, windowDay, windowMonth:
return true
default:
return false
}
}
// parseHistoryScope parses GET /admin/api/usage/history's "scope" query
// parameter: "user:{id}", "group:{id}", or the literal "total". ok is
// false for anything else, including a bare "total:{id}" form — the total
// scope carries no id component of its own, it is always totalScopeID
// (limits.go).
func parseHistoryScope(raw string) (kind, id string, ok bool) {
if raw == totalScopeKind {
return totalScopeKind, totalScopeID, true
}
k, i, found := strings.Cut(raw, ":")
if !found || i == "" {
return "", "", false
}
if k != "user" && k != "group" {
return "", "", false
}
return k, i, true
}
// parseHistorySpan parses GET /admin/api/usage/history's "span" query
// parameter for window: an empty raw value defaults to window's own
// historyMaxSpan; otherwise raw must parse as an integer between 1 and
// that max inclusive. ok is false for anything else — a non-integer, a
// value below 1, or a value beyond window's max.
func parseHistorySpan(raw, window string) (span int, ok bool) {
max := historyMaxSpan(window)
if raw == "" {
return max, true
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > max {
return 0, false
}
return n, true
}
// scopeExists reports whether kind/id names a currently active user or a
// configured group — GET /admin/api/usage/history's 404 check for an
// unknown scope id. authStore.snapshot's own listing is the same
// membership buildAdminUsage already trusts for "every currently active
// user and every configured group"; kind is assumed already restricted to
// "user" or "group" by parseHistoryScope (the total scope's id is never
// checked against it — totalScopeID always exists).
func (g *Gateway) scopeExists(kind, id string) bool {
users, groups := g.auth.snapshot()
switch kind {
case "user":
for _, u := range users {
if u.name == id {
return true
}
}
case "group":
for _, gr := range groups {
if gr.name == id {
return true
}
}
}
return false
}
// usageHistoryPointView is one bucket in GET /admin/api/usage/history's
// points array.
type usageHistoryPointView struct {
Bucket string `json:"bucket"`
Value int64 `json:"value"`
}
// usageHistoryResponse is the full body of GET /admin/api/usage/history
// (v0.2 data-layer task): scope/metric/window echoed back exactly as
// validated (scope in its raw "user:{id}"/"group:{id}"/"total" query
// form), plus span buckets oldest-first — Points[len-1] is the current,
// possibly partial, bucket.
type usageHistoryResponse struct {
Scope string `json:"scope"`
Metric string `json:"metric"`
Window string `json:"window"`
Points []usageHistoryPointView `json:"points"`
}
// serveAdminUsageHistory writes GET /admin/api/usage/history's bucketed
// series for one scope/metric/window (v0.2 data-layer task, the Vue admin
// panel's chart data source): "scope"=user:{id}|group:{id}|total,
// "metric"=req|tokin|tokout|cost, "window"=hour|day|month, optional
// "span"=N (default and max per historyMaxSpan). Validates every
// parameter before touching the store — 400 for an unrecognized scope
// kind/metric/window or an out-of-range span, 404 for a user/group id
// that names no configured entity — then reads the series via
// limiter.history, ONE storeGetMulti round trip for the whole span. A
// storeDown read answers 503, not a silently-zero series: a chart built
// on this API must never mistake "the store was unreachable" for "usage
// was genuinely zero". checkAndCount is deliberately never called here —
// see handleAdminAPI's own doc comment.
func (g *Gateway) serveAdminUsageHistory(sw *statusTrackingWriter, r *http.Request) {
q := r.URL.Query()
rawScope := q.Get("scope")
kind, id, ok := parseHistoryScope(rawScope)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown scope")
return
}
metric := q.Get("metric")
if !validHistoryMetric(metric) {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown metric")
return
}
window := q.Get("window")
if !validHistoryWindow(window) {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window")
return
}
span, ok := parseHistorySpan(q.Get("span"), window)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span")
return
}
if kind != totalScopeKind && !g.scopeExists(kind, id) {
writeOAIError(sw, http.StatusNotFound, "invalid_request_error", "unknown "+kind+" id")
return
}
points, storeOK := g.limiter.history(kind, id, metric, window, g.limiter.now(), span)
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable")
return
}
view := make([]usageHistoryPointView, len(points))
for i, p := range points {
view[i] = usageHistoryPointView{Bucket: p.bucket, Value: p.value}
}
setAdminJSONHeaders(sw)
_ = json.NewEncoder(sw).Encode(usageHistoryResponse{Scope: rawScope, Metric: metric, Window: window, Points: view})
}
// 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.
//
// Browser key entry (controller-approved amendment, 2026-08-20 review):
// the page itself carries no auth (see handleAdmin), so its script reads
// an admin API key from this tab's sessionStorage and sends it as
// "x-api-key" on every /admin/api/* fetch. Absent a stored key, or on any
// 401/403 response, it shows an inline key-entry form instead of the
// dashboard; a submitted key is kept only in sessionStorage — never in a
// cookie, localStorage, or any persistent store — so it disappears when
// the tab closes and is never sent anywhere but this page's own fetches.
const adminPageHTML = `
LLM Gateway - Admin
LLM Gateway - Admin
loading...
Admin key required
Enter an admin API key. The browser keeps it only in this tab's sessionStorage: it is never written to disk and is sent only to this page's own /admin/api/* requests.
Providers
| Name | Type | Base URL | Models | Last refresh | Last error |
Groups
| Name | Members | Limits | req/min | req/day | tokIn/day | tokOut/day | tokIn/month | tokOut/month | cost/day | cost/month |
Users
| Name | Group | Limits | req/min | req/day | tokIn/day | tokOut/day | tokIn/month | tokOut/month | cost/day | cost/month |
`