Files
lukaszraczylo 65b2f121c4 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
2026-09-23 16:40:45 +01:00

1131 lines
39 KiB
Go

package traefikllmgateway
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
// adminUsageSeriesPath, adminUsageTotalsPath and adminPerformancePath are
// GET /admin/api/usage/series, GET /admin/api/usage/totals and GET
// /admin/api/performance's routes (admin dashboard redesign, WP-B) —
// defined here, alongside the rest of these features' own constants,
// mirroring adminEventsPath's own precedent (events.go).
const (
adminUsageSeriesPath = "/admin/api/usage/series"
adminUsageTotalsPath = "/admin/api/usage/totals"
adminPerformancePath = "/admin/api/performance"
)
// adminMaxKeysPerRequest is the hard cap on how many counterStore keys any
// single stats_read.go endpoint may read in one request (DECISIONS Q8): a
// caller whose scope/span/offset/metric combination would read more than
// this many keys gets 400 "range too large; narrow span or filter"
// instead of an unbounded pipeline against the shared store.
const adminMaxKeysPerRequest = 64000
// adminSeriesMaxScopes bounds GET /admin/api/usage/series' repeated
// "scope" query parameter (plan §1.3(iv): "1..100").
const adminSeriesMaxScopes = 100
// parseStatsOffset parses an optional "offset" query parameter shared by
// GET /admin/api/usage/models (admin.go), .../usage/series, .../usage/
// totals and .../performance: empty means 0 (the current, most-recent
// span — every endpoint's own pre-offset behavior), otherwise an integer
// between 0 and window's historyMaxSpan minus the already-validated span,
// inclusive — span+offset never exceeds what that window's TTL could ever
// have kept alive (historyMaxSpan's own doc comment, admin.go). ok is
// false for anything else.
func parseStatsOffset(raw, window string, span int) (offset int, ok bool) {
if raw == "" {
return 0, true
}
max := historyMaxSpan(window) - span
n, err := strconv.Atoi(raw)
if err != nil || n < 0 || n > max {
return 0, false
}
return n, true
}
// readSpanTotals reads, for every id in ids (all sharing kind), each
// metric in metrics summed over span buckets ending offset window-steps
// back from now, keyed by id then metric name. P6/P12 fix (admin
// dashboard redesign verify round): this used to build its own single
// unbounded storeGetMulti pipeline for the whole batch; it now delegates
// to limits.go's spanTotalsMulti — the ONE production span-sum reader
// admin.go's chunkedModelSpanTotals/chunkedModelSpanTotalsMulti also
// call — which chunks every read at usageModelsChunkKeys keys per round
// trip. ok is false on any store read failure or a fail-open outage in
// progress (configuredStoreDown), exactly like every other admin stats
// reader in this package — spanTotalsMulti itself already applies that
// check per chunk.
func (g *Gateway) readSpanTotals(kind string, ids, metrics []string, window string, now time.Time, span, offset int) (map[string]map[string]int64, bool) {
out := make(map[string]map[string]int64, len(ids))
if len(ids) == 0 || len(metrics) == 0 {
return out, true
}
totalsByMetric, ok := g.limiter.spanTotalsMulti(kind, ids, metrics, window, now, span, offset)
if !ok {
return nil, false
}
for i, id := range ids {
m := make(map[string]int64, len(metrics))
for mi, metric := range metrics {
m[metric] = totalsByMetric[mi][i]
}
out[id] = m
}
return out, true
}
// ---------------------------------------------------------------------
// GET /admin/api/usage/series
// ---------------------------------------------------------------------
// adminSeriesView is one requested scope's bucketed series in GET
// /admin/api/usage/series.
type adminSeriesView struct {
Scope string `json:"scope"`
Points []int64 `json:"points"`
}
// adminSeriesResponse is the full body of GET /admin/api/usage/series.
type adminSeriesResponse struct {
Metric string `json:"metric"`
Window string `json:"window"`
Buckets []string `json:"buckets"` // oldest first
Series []adminSeriesView `json:"series"`
Unknown []string `json:"unknown"` // never nil
Span int `json:"span"`
Offset int `json:"offset"`
}
// parseSeriesScope parses one GET /admin/api/usage/series "scope" value:
// "total", "user:{id}", "group:{id}", "model:{canonical}",
// "provider:{name}" or "provmodel:{canonical}". ok is false for anything
// else.
func parseSeriesScope(raw string) (kind, id string, ok bool) {
if raw == totalScopeKind {
return totalScopeKind, totalScopeID, true
}
prefix, rest, found := strings.Cut(raw, ":")
if !found || rest == "" {
return "", "", false
}
switch prefix {
case "user", "group":
return prefix, rest, true
case "model":
return kindModel, rest, true
case "provider":
return kindProvider, rest, true
case "provmodel":
return kindProviderModel, rest, true
default:
return "", "", false
}
}
// seriesScopeExists extends scopeExists (admin.go) to also validate a
// kindProvider or kindProviderModel scope against the live registry —
// the two additional scope kinds GET /admin/api/usage/series accepts
// that GET /admin/api/usage/history (scopeExists' only other caller)
// never did.
func (g *Gateway) seriesScopeExists(kind, id string) bool {
switch kind {
case kindProvider:
for _, s := range g.registry.snapshot() {
if s.name == id {
return true
}
}
return false
case kindProviderModel:
return g.scopeExists(kindModel, id)
default:
return g.scopeExists(kind, id)
}
}
// seriesValidMetric reports whether metric is a valid reading for
// scopeKind at window — plan §1.3(iv)'s metric x scope-kind matrix.
func seriesValidMetric(scopeKind, metric, window string) bool {
hourOrDay := window == windowHour || window == windowDay
switch scopeKind {
case "user", "group", totalScopeKind:
switch metric {
case metricReq, metricTokIn, metricTokOut, metricCost:
return true
case metricRej:
return hourOrDay
}
if scopeKind == totalScopeKind {
switch metric {
case metricChit, metricCmiss, metricCsave, metricR402:
return hourOrDay
}
}
return false
case kindModel:
switch metric {
case metricReq, metricTokIn, metricTokOut, metricCost:
return true
case metricChit, metricCsave, metricR402:
return window == windowDay
}
return false
case kindProvider:
switch metric {
case metricProvAttempt, metricProvFail, metricProvTimeout, metricFover:
return hourOrDay
}
return false
case kindProviderModel:
switch metric {
case metricProvAttempt, metricProvFail:
return hourOrDay
}
return false
default:
return false
}
}
// serveAdminUsageSeries writes GET /admin/api/usage/series: one or more
// scopes' bucketed history for a single metric/window/span/offset, in one
// batched store read. Every parameter is validated before the store is
// touched (400 for anything malformed or out of range); an unknown scope
// id is skipped and listed in Unknown rather than failing the whole
// request, mirroring the free-models ranking's own "skip, don't fail"
// convention for an individual bad element within an otherwise valid
// request.
func (g *Gateway) serveAdminUsageSeries(sw *statusTrackingWriter, r *http.Request) {
q := r.URL.Query()
rawScopes := q["scope"]
if len(rawScopes) == 0 {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "at least one scope is required")
return
}
if len(rawScopes) > adminSeriesMaxScopes {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "too many scopes")
return
}
window := q.Get("window")
if !validHistoryWindow(window) {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window")
return
}
metric := q.Get("metric")
if !validHistoryMetric(metric) && !isKnownStatsMetric(metric) {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown metric")
return
}
span, ok := parseHistorySpan(q.Get("span"), window)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span")
return
}
offset, ok := parseStatsOffset(q.Get("offset"), window, span)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset")
return
}
type parsedScope struct {
raw, kind, id string
}
parsed := make([]parsedScope, 0, len(rawScopes))
unknown := []string{}
for _, raw := range rawScopes {
kind, id, ok := parseSeriesScope(raw)
if !ok {
unknown = append(unknown, raw)
continue
}
if !seriesValidMetric(kind, metric, window) {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown metric for scope kind")
return
}
if kind != totalScopeKind && !g.seriesScopeExists(kind, id) {
unknown = append(unknown, raw)
continue
}
parsed = append(parsed, parsedScope{raw: raw, kind: kind, id: id})
}
if len(parsed)*span > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
now := g.limiter.now()
var buckets []string
series := make([]adminSeriesView, 0, len(parsed))
allKeys := make([]string, 0, len(parsed)*span)
for _, p := range parsed {
keys, bkts := historyBucketKeysAt(p.kind, p.id, metric, window, now, span, offset)
if buckets == nil {
buckets = bkts
}
allKeys = append(allKeys, keys...)
}
// P6 fix (admin dashboard redesign verify round): chunked at
// usageModelsChunkKeys keys per round trip (chunkedGetMulti, limits.go)
// instead of one unbounded pipeline covering every requested scope's
// whole span at once — a 100-scope request at span=48 used to build a
// single 4,800-key pipeline in one call.
vals, storeOK := g.limiter.chunkedGetMulti(allKeys, usageModelsChunkKeys)
if !storeOK || len(vals) != len(allKeys) {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
return
}
for i, p := range parsed {
points := append([]int64(nil), vals[i*span:i*span+span]...)
series = append(series, adminSeriesView{Scope: p.raw, Points: points})
}
if buckets == nil {
buckets = []string{}
}
setAdminJSONHeaders(sw)
_ = json.NewEncoder(sw).Encode(adminSeriesResponse{
Metric: metric, Window: window, Span: span, Offset: offset,
Buckets: buckets, Series: series, Unknown: unknown,
})
}
// isKnownStatsMetric reports whether metric is one of the admin-redesign
// counter families' metric names (fover/timeout/r402/chit/cmiss/csave/
// attempt/fail) — validHistoryMetric (admin.go) only ever knew the four
// original req/tokin/tokout/cost metrics, so GET /admin/api/usage/series
// and GET /admin/api/usage/totals — the two endpoints that also read
// these newer families — check both.
func isKnownStatsMetric(metric string) bool {
switch metric {
case metricRej, metricProvAttempt, metricProvFail, metricProvTimeout, metricFover,
metricR402, metricChit, metricCmiss, metricCsave:
return true
default:
return false
}
}
// ---------------------------------------------------------------------
// GET /admin/api/usage/totals
// ---------------------------------------------------------------------
// adminTotalsRow is one entity's metric values in GET
// /admin/api/usage/totals. ID's shape depends on Kind: a plain name for
// user/group, a canonical "provider/model" id for usermodel, a user name
// for modeluser, or "mcp/{target}/{caller}"/"agent/{target}/{caller}"
// for targetcaller (targetCallerScopeID's own format, limits.go).
type adminTotalsRow struct {
Values map[string]int64 `json:"values"`
ID string `json:"id"`
}
// adminTotalsResponse is the full body of GET /admin/api/usage/totals.
type adminTotalsResponse struct {
Kind string `json:"kind"`
Window string `json:"window"`
Metrics []string `json:"metrics"`
Rows []adminTotalsRow `json:"rows"`
Span int `json:"span"`
Offset int `json:"offset"`
Truncated bool `json:"truncated,omitempty"`
}
const (
adminTotalsDefaultLimit = 200
adminTotalsMaxLimit = 1000
)
// totalsKindMetrics enumerates every metric allowed for kind, in a fixed
// order — the default `metrics` list when a request omits that query
// parameter, and the set a caller's own comma list is validated against
// (plan §1.3(v): "usermodel/modeluser/targetcaller: day/month only;
// req/tokin/tokout/cost (targetcaller req only)").
func totalsKindMetrics(kind string) []string {
switch kind {
case "user", "group", "usermodel", "modeluser":
return []string{metricReq, metricTokIn, metricTokOut, metricCost}
case "targetcaller":
return []string{metricReq}
default:
return nil
}
}
// totalsValidWindow reports whether window is valid for kind: usermodel/
// modeluser/targetcaller accept only day/month (plan §1.3(v)); user/group
// accept any of hour/day/month (validHistoryWindow, admin.go).
func totalsValidWindow(kind, window string) bool {
switch kind {
case "usermodel", "modeluser", "targetcaller":
return window == windowDay || window == windowMonth
default:
return validHistoryWindow(window)
}
}
// parseTotalsMetrics parses GET /admin/api/usage/totals' optional
// "metrics" query parameter: empty means every metric totalsKindMetrics
// allows for kind, in that fixed order; otherwise a comma-separated list,
// each entry one of those allowed metrics, no duplicates. ok is false for
// an unknown metric, a duplicate, or an empty list.
func parseTotalsMetrics(raw, kind string) (metrics []string, ok bool) {
allowed := totalsKindMetrics(kind)
if raw == "" {
return allowed, true
}
allowedSet := make(map[string]bool, len(allowed))
for _, m := range allowed {
allowedSet[m] = true
}
parts := strings.Split(raw, ",")
seen := make(map[string]bool, len(parts))
out := make([]string, 0, len(parts))
for _, p := range parts {
if !allowedSet[p] || seen[p] {
return nil, false
}
seen[p] = true
out = append(out, p)
}
return out, true
}
// parseTotalsLimit parses GET /admin/api/usage/totals' optional "limit"
// query parameter: empty means adminTotalsDefaultLimit, otherwise an
// integer between 1 and adminTotalsMaxLimit inclusive.
func parseTotalsLimit(raw string) (limit int, ok bool) {
if raw == "" {
return adminTotalsDefaultLimit, true
}
n, err := strconv.Atoi(raw)
if err != nil || n < 1 || n > adminTotalsMaxLimit {
return 0, false
}
return n, true
}
// parseTargetRef parses a "target" query value ("mcp/{name}" or
// "agent/{name}") into its kind ("mcp"|"agent") and name.
func parseTargetRef(raw string) (kind, name string, ok bool) {
k, n, found := strings.Cut(raw, "/")
if !found || n == "" {
return "", "", false
}
if k != "mcp" && k != "agent" {
return "", "", false
}
return k, n, true
}
// stringSliceContains reports whether s contains v.
func stringSliceContains(s []string, v string) bool {
for _, x := range s {
if x == v {
return true
}
}
return false
}
// totalsCandidate is one row-to-be, before the drop-all-zero/sort/limit
// pass buildTotalsRows applies.
type totalsCandidate struct {
values map[string]int64
id string
}
// buildTotalsRows drops every candidate whose every requested metric is
// zero, sorts the rest by metrics[0] descending then id ascending (plan
// §1.3(v): "Drop all-zero rows; sort by first metric desc then id"), and
// truncates to limit, reporting whether it did.
func buildTotalsRows(candidates []totalsCandidate, metrics []string, limit int) ([]adminTotalsRow, bool) {
rows := make([]adminTotalsRow, 0, len(candidates))
for _, c := range candidates {
allZero := true
for _, m := range metrics {
if c.values[m] != 0 {
allZero = false
break
}
}
if allZero {
continue
}
rows = append(rows, adminTotalsRow{ID: c.id, Values: c.values})
}
first := metrics[0]
sort.Slice(rows, func(i, j int) bool {
vi, vj := rows[i].Values[first], rows[j].Values[first]
if vi != vj {
return vi > vj
}
return rows[i].ID < rows[j].ID
})
truncated := len(rows) > limit
if truncated {
rows = rows[:limit]
}
return rows, truncated
}
// serveAdminUsageTotals writes GET /admin/api/usage/totals: ranked
// per-entity metric totals for one of five kinds (plan §1.3(v)).
// usermodel/modeluser additionally 404 when admin.stats.userModel is off
// (parity with GET /admin/api/usage/models' own detail extras).
func (g *Gateway) serveAdminUsageTotals(sw *statusTrackingWriter, r *http.Request) {
q := r.URL.Query()
kind := q.Get("kind")
switch kind {
case "user", "group", "usermodel", "modeluser", "targetcaller":
default:
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown kind")
return
}
window := q.Get("window")
if window == "" {
window = windowDay
}
if !totalsValidWindow(kind, window) {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window")
return
}
span, ok := parseUsageModelsSpan(q.Get("span"), window)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span")
return
}
offset, ok := parseStatsOffset(q.Get("offset"), window, span)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset")
return
}
metrics, ok := parseTotalsMetrics(q.Get("metrics"), kind)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid metrics")
return
}
limit, ok := parseTotalsLimit(q.Get("limit"))
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid limit")
return
}
userParam := q.Get("user")
if kind == "usermodel" && userParam == "" {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "user is required for kind=usermodel")
return
}
modelParam := q.Get("model")
if kind == "modeluser" && modelParam == "" {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "model is required for kind=modeluser")
return
}
var targetKind, targetName string
if raw := q.Get("target"); kind == "targetcaller" && raw != "" {
targetKind, targetName, ok = parseTargetRef(raw)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid target")
return
}
}
// Feature gate checked LAST, after every parameter is already known
// valid (plan §1.3's intro paragraph: "validate params first... 404
// ...statistics are not enabled" — a malformed request must always
// answer 400 for the thing that is actually wrong with it, never a
// 404 that reads as "this whole kind doesn't exist" when the real
// problem is a missing/invalid parameter the caller can fix on its
// own regardless of whether the feature is enabled).
if (kind == "usermodel" || kind == "modeluser") && !g.buildAdminFeatures().UserModelStats {
writeOAIError(sw, http.StatusNotFound, "invalid_request_error", "per-user-model statistics are not enabled (admin.stats.userModel)")
return
}
now := g.limiter.now()
userSummaries, groupSummaries := g.auth.snapshot()
var candidates []totalsCandidate
var storeOK bool
switch kind {
case "user":
ids := make([]string, 0, len(userSummaries))
for _, us := range userSummaries {
if grp := q.Get("group"); grp != "" && !stringSliceContains(us.groups, grp) {
continue
}
ids = append(ids, us.name)
}
if len(ids)*len(metrics)*span > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
var raw map[string]map[string]int64
raw, storeOK = g.readSpanTotals("user", ids, metrics, window, now, span, offset)
candidates = make([]totalsCandidate, len(ids))
for i, id := range ids {
candidates[i] = totalsCandidate{id: id, values: raw[id]}
}
case "group":
ids := make([]string, len(groupSummaries))
for i, gs := range groupSummaries {
ids[i] = gs.name
}
if len(ids)*len(metrics)*span > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
var raw map[string]map[string]int64
raw, storeOK = g.readSpanTotals("group", ids, metrics, window, now, span, offset)
candidates = make([]totalsCandidate, len(ids))
for i, id := range ids {
candidates[i] = totalsCandidate{id: id, values: raw[id]}
}
case "usermodel":
// Cheap phase (plan §1.3(v)): narrow the full catalog down to
// models with ANY recent usage at all, via a single-metric read
// over covering month buckets, before ever touching a per-user
// umodel counter — the catalog can run to hundreds of models,
// while a real user typically used only a handful of them.
modelIDs := g.catalogModelScopeIDs()
coverSpan := historyMaxSpan(windowMonth)
if len(modelIDs)*coverSpan > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
cheap, cheapOK := g.readSpanTotals(kindModel, modelIDs, []string{metricReq}, windowMonth, now, coverSpan, 0)
if !cheapOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
return
}
var nonzero []string
for _, id := range modelIDs {
if cheap[id][metricReq] != 0 {
nonzero = append(nonzero, id)
}
}
umodelIDs := make([]string, len(nonzero))
for i, canonical := range nonzero {
umodelIDs[i] = userModelScopeID(userParam, canonical)
}
if len(umodelIDs)*len(metrics)*span > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
var raw map[string]map[string]int64
raw, storeOK = g.readSpanTotals(kindUserModel, umodelIDs, metrics, window, now, span, offset)
candidates = make([]totalsCandidate, len(nonzero))
for i, canonical := range nonzero {
candidates[i] = totalsCandidate{id: canonical, values: raw[umodelIDs[i]]}
}
case "modeluser":
umodelIDs := make([]string, len(userSummaries))
for i, us := range userSummaries {
umodelIDs[i] = userModelScopeID(us.name, modelParam)
}
if len(umodelIDs)*len(metrics)*span > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
var raw map[string]map[string]int64
raw, storeOK = g.readSpanTotals(kindUserModel, umodelIDs, metrics, window, now, span, offset)
candidates = make([]totalsCandidate, len(userSummaries))
for i, us := range userSummaries {
candidates[i] = totalsCandidate{id: us.name, values: raw[umodelIDs[i]]}
}
case "targetcaller":
type targetRef struct{ kind, name string }
var targets []targetRef
if targetName != "" {
targets = []targetRef{{targetKind, targetName}}
} else {
for _, name := range sortedMCPServerNames(g.cfg.MCPServers) {
targets = append(targets, targetRef{"mcp", name})
}
for _, name := range sortedAgentNames(g.cfg.Agents) {
targets = append(targets, targetRef{"agent", name})
}
}
ids := make([]string, 0, len(targets)*len(userSummaries))
for _, t := range targets {
for _, us := range userSummaries {
ids = append(ids, targetCallerScopeID(t.kind, t.name, us.name))
}
}
if len(ids)*len(metrics)*span > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
var raw map[string]map[string]int64
raw, storeOK = g.readSpanTotals(kindTargetCaller, ids, metrics, window, now, span, offset)
candidates = make([]totalsCandidate, len(ids))
for i, id := range ids {
candidates[i] = totalsCandidate{id: id, values: raw[id]}
}
}
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
return
}
rows, truncated := buildTotalsRows(candidates, metrics, limit)
setAdminJSONHeaders(sw)
_ = json.NewEncoder(sw).Encode(adminTotalsResponse{
Kind: kind, Window: window, Metrics: metrics, Rows: rows,
Span: span, Offset: offset, Truncated: truncated,
})
}
// ---------------------------------------------------------------------
// GET /admin/api/performance
// ---------------------------------------------------------------------
// adminPerfMaxIDs bounds GET /admin/api/performance's repeated "id" query
// parameter (plan §1.3(vi): "<=50").
const adminPerfMaxIDs = 50
// adminPerfRow is one entity's (provider's or model's) availability and
// latency summary in GET /admin/api/performance, over the requested span.
// The five *Ms percentile fields and Count are all latency-derived and
// stay at their zero value (nil pointers, Count 0) when latency stats are
// disabled (LatencyEnabled false) or this entity has no latency
// observations at all in the span; Attempts/Failures[/Timeouts/
// Failovers] are always populated regardless.
type adminPerfRow struct {
P50Ms *float64 `json:"p50Ms,omitempty"`
P95Ms *float64 `json:"p95Ms,omitempty"`
P99Ms *float64 `json:"p99Ms,omitempty"`
TTFBP50Ms *float64 `json:"ttfbP50Ms,omitempty"`
TTFBP95Ms *float64 `json:"ttfbP95Ms,omitempty"`
ID string `json:"id"`
Count int64 `json:"count"`
Attempts int64 `json:"attempts"`
Failures int64 `json:"failures"`
// Timeouts and Failovers are populated only for kind=provider — a
// per-(provider,model) scope tracks neither (plan §1.2's family
// table: prov/{name} only) — so they stay 0 (omitted) for kind=model.
Timeouts int64 `json:"timeouts,omitempty"`
Failovers int64 `json:"failovers,omitempty"`
Overflow bool `json:"overflow,omitempty"`
}
// adminPerfResponse is the full body of GET /admin/api/performance.
// Rows is populated for the default (series=0) aggregate mode — one row
// per entity, summed over the whole span. Buckets/Points are populated
// only for series=1 (exactly one id) instead: one adminPerfRow per
// bucket, ID set to that bucket's own label. Rows is ALWAYS present and
// NEVER nil (an empty array `[]`, never `null`, in series=1 mode or when
// there are zero rows to report) — every other admin stats endpoint's own
// array-typed field (adminSeriesResponse.Unknown, adminUsageModelsResponse
// .Models, ...) already carries this same "never nil" contract, and a
// client that always does `for (const r of resp.rows)` without a null
// check must never see this one field be the exception.
type adminPerfResponse struct {
Kind string `json:"kind"`
Window string `json:"window"`
Rows []adminPerfRow `json:"rows"`
Buckets []string `json:"buckets,omitempty"`
Points []adminPerfRow `json:"points,omitempty"`
Span int `json:"span"`
Offset int `json:"offset"`
LatencyEnabled bool `json:"latencyEnabled"`
}
// performanceValidWindow reports whether window is one of GET
// /admin/api/performance's two accepted values (plan §1.3(vi): "window
// hour|day" — no month, unlike most other stats endpoints: a monthly
// latency percentile would smear together far too much variance to be
// actionable).
func performanceValidWindow(window string) bool {
return window == windowHour || window == windowDay
}
// latencyMetricNames returns every latencyDurationMetric/
// latencyTTFBMetric name (limits.go) for the full bucket range: indices
// 0..len(latencyBucketBounds)-1 for each real bound, plus
// len(latencyBucketBounds) itself for the overflow bucket — matching
// latencyBucketIndex's own return range (metrics.go, WP-A).
func latencyMetricNames() []string {
n := len(latencyBucketBounds) + 1
names := make([]string, 0, 2*n)
for i := 0; i < n; i++ {
names = append(names, latencyDurationMetric(i))
}
for i := 0; i < n; i++ {
names = append(names, latencyTTFBMetric(i))
}
return names
}
// histogramQuantile computes the linearly-interpolated q-quantile (0..1)
// of a dense latency histogram: bounds are latencyBucketBounds' own
// ascending bucket upper bounds, in SECONDS; counts has len(bounds)+1
// entries — counts[i] is bounds[i]'s own dense occurrence count for
// i < len(bounds), and counts[len(bounds)] is the overflow bucket (every
// observation past the largest bound). Returns ms (the quantile value in
// MILLISECONDS) and overflow=true when the computed rank falls in the
// overflow bucket, in which case ms is the largest bound in ms — the best
// lower bound available, since an overflow observation's true value is
// unknown past that point (plan §1.3(vi): "overflow bucket -> 600000 +
// overflow:true"). (0, false) when counts holds no observations at all.
func histogramQuantile(bounds []float64, counts []int64, q float64) (ms float64, overflow bool) {
var total int64
for _, c := range counts {
total += c
}
if total == 0 {
return 0, false
}
target := q * float64(total)
var cum float64
lower := 0.0
for i, bound := range bounds {
c := float64(counts[i])
if cum+c >= target {
if c <= 0 {
return bound * 1000, false
}
frac := (target - cum) / c
return (lower + frac*(bound-lower)) * 1000, false
}
cum += c
lower = bound
}
return bounds[len(bounds)-1] * 1000, true
}
// perfScopeKinds resolves the counterStore scope kind for a performance
// entity's core availability counters (attempt/fail[/timeout/fover]) and
// its latency buckets, given the endpoint's own "kind" query value
// ("provider"|"model"): a provider entity reads both families under
// kindProvider (id = provider name); a model entity reads its core
// counters under kindProviderModel (attempt/fail only — plan §1.2's
// family table has no per-model timeout/fover) and its latency buckets
// under kindModel (id = canonical "provider/model" either way).
func perfScopeKinds(kind string) (coreKind, latencyKind string) {
if kind == "model" {
return kindProviderModel, kindModel
}
return kindProvider, kindProvider
}
// buildPerfRow assembles one adminPerfRow from its already-read core
// counters (core, never nil) and latency bucket counts (lat, nil when
// latency stats are disabled or unread).
func buildPerfRow(id, coreKind string, core, lat map[string]int64, latencyEnabled bool) adminPerfRow {
row := adminPerfRow{
ID: id,
Attempts: core[metricProvAttempt],
Failures: core[metricProvFail],
}
if coreKind == kindProvider {
row.Timeouts = core[metricProvTimeout]
row.Failovers = core[metricFover]
}
if !latencyEnabled || lat == nil {
return row
}
n := len(latencyBucketBounds) + 1
durationCounts := make([]int64, n)
ttfbCounts := make([]int64, n)
var durTotal, ttfbTotal int64
for i := 0; i < n; i++ {
durationCounts[i] = lat[latencyDurationMetric(i)]
ttfbCounts[i] = lat[latencyTTFBMetric(i)]
durTotal += durationCounts[i]
ttfbTotal += ttfbCounts[i]
}
row.Count = durTotal
var overflow bool
if durTotal > 0 {
p50, of := histogramQuantile(latencyBucketBounds, durationCounts, 0.5)
row.P50Ms = &p50
overflow = overflow || of
p95, of := histogramQuantile(latencyBucketBounds, durationCounts, 0.95)
row.P95Ms = &p95
overflow = overflow || of
p99, of := histogramQuantile(latencyBucketBounds, durationCounts, 0.99)
row.P99Ms = &p99
overflow = overflow || of
}
if ttfbTotal > 0 {
t50, of := histogramQuantile(latencyBucketBounds, ttfbCounts, 0.5)
row.TTFBP50Ms = &t50
overflow = overflow || of
t95, of := histogramQuantile(latencyBucketBounds, ttfbCounts, 0.95)
row.TTFBP95Ms = &t95
overflow = overflow || of
}
row.Overflow = overflow
return row
}
// serveAdminPerformance writes GET /admin/api/performance: provider or
// model availability + (opt-in) latency percentiles, aggregated over the
// requested span (series=0, the default) or bucket-by-bucket for exactly
// one id (series=1).
func (g *Gateway) serveAdminPerformance(sw *statusTrackingWriter, r *http.Request) {
q := r.URL.Query()
kind := q.Get("kind")
if kind != "provider" && kind != "model" {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown kind")
return
}
window := q.Get("window")
if !performanceValidWindow(window) {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window")
return
}
span, ok := parseUsageModelsSpan(q.Get("span"), window)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span")
return
}
offset, ok := parseStatsOffset(q.Get("offset"), window, span)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset")
return
}
rawIDs := q["id"]
if len(rawIDs) > adminPerfMaxIDs {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "too many ids")
return
}
seriesRaw := q.Get("series")
var series bool
switch seriesRaw {
case "", "0":
series = false
case "1":
series = true
default:
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid series")
return
}
if series && len(rawIDs) != 1 {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "series requires exactly one id")
return
}
now := g.limiter.now()
coreKind, latencyKind := perfScopeKinds(kind)
latencyEnabled := g.buildAdminFeatures().LatencyStats
var ids []string
if len(rawIDs) > 0 {
for _, id := range rawIDs {
if kind == "provider" && g.seriesScopeExists(kindProvider, id) {
ids = append(ids, id)
} else if kind == "model" && g.scopeExists(kindModel, id) {
ids = append(ids, id)
}
}
} else if kind == "provider" {
for _, s := range g.registry.snapshot() {
ids = append(ids, s.name)
}
} else {
// Default: top 50 by request count in span (plan §1.3(vi)).
catalog := g.catalogModelScopeIDs()
// P7 fix (admin dashboard redesign verify round): this cap used to
// check len(catalog) alone, ignoring span entirely — the actual
// read below is len(catalog)*span keys (one metricReq bucket per
// model per span step), so a wide catalog combined with a large
// span could read far more than adminMaxKeysPerRequest ever meant
// to allow through before this 400 ever triggered.
if len(catalog)*span > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
totals, storeOK := g.chunkedModelSpanTotals(catalog, metricReq, window, now, span, offset)
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
return
}
type ranked struct {
id string
req int64
}
var nonzero []ranked
for i, id := range catalog {
if totals[i] > 0 {
nonzero = append(nonzero, ranked{id: id, req: totals[i]})
}
}
sort.Slice(nonzero, func(i, j int) bool {
if nonzero[i].req != nonzero[j].req {
return nonzero[i].req > nonzero[j].req
}
return nonzero[i].id < nonzero[j].id
})
if len(nonzero) > 50 {
nonzero = nonzero[:50]
}
for _, rk := range nonzero {
ids = append(ids, rk.id)
}
}
// P1 fix (admin dashboard redesign verify round): the earlier
// `len(rawIDs) != 1` check only bounds how many id= parameters the
// CALLER sent — it says nothing about how many of them actually
// resolved to a real provider/model above. An unknown or renamed id
// (`series=1&id=nope`) resolves ids to an EMPTY slice, and ids[0]
// below panicked (recovered as a 500 "internal error" by errors.go's
// recoverPanic, but still a logged panic on every dashboard click
// against a stale id) instead of the 400 this endpoint's own README
// section already promised.
if series && len(ids) != 1 {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "series requires exactly one id")
return
}
if series {
buckets, points, storeOK := g.perfSeries(coreKind, latencyKind, ids[0], window, now, span, offset, latencyEnabled)
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
return
}
setAdminJSONHeaders(sw)
_ = json.NewEncoder(sw).Encode(adminPerfResponse{
Kind: kind, Window: window, Span: span, Offset: offset,
// Rows stays non-nil even though series=1 never populates it —
// see adminPerfResponse's own "never nil" doc comment.
Rows: []adminPerfRow{}, Buckets: buckets, Points: points, LatencyEnabled: latencyEnabled,
})
return
}
coreMetrics := []string{metricProvAttempt, metricProvFail}
if coreKind == kindProvider {
coreMetrics = append(coreMetrics, metricProvTimeout, metricFover)
}
estimateKeys := len(ids) * len(coreMetrics) * span
if latencyEnabled {
estimateKeys += len(ids) * len(latencyMetricNames()) * span
}
if estimateKeys > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
coreVals, storeOK := g.readSpanTotals(coreKind, ids, coreMetrics, window, now, span, offset)
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
return
}
var latVals map[string]map[string]int64
if latencyEnabled {
latVals, storeOK = g.readSpanTotals(latencyKind, ids, latencyMetricNames(), window, now, span, offset)
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable")
return
}
}
rows := make([]adminPerfRow, len(ids))
for i, id := range ids {
rows[i] = buildPerfRow(id, coreKind, coreVals[id], latVals[id], latencyEnabled)
}
setAdminJSONHeaders(sw)
_ = json.NewEncoder(sw).Encode(adminPerfResponse{
Kind: kind, Window: window, Span: span, Offset: offset,
Rows: rows, LatencyEnabled: latencyEnabled,
})
}
// perfSeries reads bucket-by-bucket (never summed) core+latency counters
// for exactly one entity — GET /admin/api/performance's series=1 mode —
// returning span bucket labels and one adminPerfRow per bucket, ID set to
// that bucket's own label.
func (g *Gateway) perfSeries(coreKind, latencyKind, id, window string, now time.Time, span, offset int, latencyEnabled bool) (buckets []string, points []adminPerfRow, ok bool) {
coreMetrics := []string{metricProvAttempt, metricProvFail}
if coreKind == kindProvider {
coreMetrics = append(coreMetrics, metricProvTimeout, metricFover)
}
var latMetrics []string
if latencyEnabled {
latMetrics = latencyMetricNames()
}
// P6 fix (admin dashboard redesign verify round): every metric used to
// read via its own separate storeGetMulti round trip — a provider's
// series=1 with latency on cost 4 core + 28 latency metrics = 32
// sequential round trips. Every metric's keys are built up front into
// ONE flat list instead, read through chunkedGetMulti's own bounded
// pipelining (limits.go), and sliced back apart per metric below —
// buckets come from the first metric's own key build since every
// metric shares identical bucket labels for the same kind/id/window/
// span/offset.
allKeys := make([]string, 0, (len(coreMetrics)+len(latMetrics))*span)
for _, m := range coreMetrics {
keys, bkts := historyBucketKeysAt(coreKind, id, m, window, now, span, offset)
if buckets == nil {
buckets = bkts
}
allKeys = append(allKeys, keys...)
}
for _, m := range latMetrics {
keys, _ := historyBucketKeysAt(latencyKind, id, m, window, now, span, offset)
allKeys = append(allKeys, keys...)
}
vals, storeOK := g.limiter.chunkedGetMulti(allKeys, usageModelsChunkKeys)
if !storeOK || len(vals) != len(allKeys) {
return nil, nil, false
}
coreVals := make(map[string][]int64, len(coreMetrics))
idx := 0
for _, m := range coreMetrics {
coreVals[m] = vals[idx : idx+span]
idx += span
}
latVals := make(map[string][]int64, len(latMetrics))
for _, m := range latMetrics {
latVals[m] = vals[idx : idx+span]
idx += span
}
points = make([]adminPerfRow, span)
for b := 0; b < span; b++ {
core := make(map[string]int64, len(coreMetrics))
for _, m := range coreMetrics {
core[m] = coreVals[m][b]
}
var lat map[string]int64
if latencyEnabled {
lat = make(map[string]int64, len(latMetrics))
for _, m := range latMetrics {
lat[m] = latVals[m][b]
}
}
row := buildPerfRow(buckets[b], coreKind, core, lat, latencyEnabled)
points[b] = row
}
return buckets, points, true
}