mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-11 04:47:00 +00:00
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
2164 lines
98 KiB
Go
2164 lines
98 KiB
Go
package traefikllmgateway
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/url"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// The admin dashboard's routes (spec §4, v0.2; adminUsageHistoryPath added
|
||
// by the v0.2 data-layer task; adminAssetsPathPrefix added by the Vue
|
||
// admin-panel task; adminTargetsPath added by Feature B, v0.21), matched
|
||
// only when adminEnabled(g.cfg) — see llmgateway.go's ServeHTTP dispatch.
|
||
// adminAssetsPathPrefix is a prefix, not one fixed path: every hashed
|
||
// filename the Vite build emits (admin_assets_gen.go) is served under it.
|
||
const (
|
||
adminPagePath = "/admin"
|
||
adminAssetsPathPrefix = "/admin/assets/"
|
||
adminOverviewPath = "/admin/api/overview"
|
||
adminUsagePath = "/admin/api/usage"
|
||
adminUsageHistoryPath = "/admin/api/usage/history"
|
||
adminUsageModelsPath = "/admin/api/usage/models"
|
||
adminTargetsPath = "/admin/api/targets"
|
||
)
|
||
|
||
// adminCSP is the Content-Security-Policy header served with GET /admin,
|
||
// every GET /admin/assets/{hashedname} response (serveAdminAsset), and
|
||
// every /admin/api/* JSON route — every /admin* response this
|
||
// gateway ever sends (spec §4, v0.2; extended to the JSON routes by a
|
||
// folded review item, 2026-08-20 review; extended to the asset route by
|
||
// a later review sweep — see serveAdminAsset's own doc comment for why).
|
||
// It was
|
||
// widened with `script-src 'unsafe-inline'`/`style-src 'unsafe-inline'`
|
||
// for the original vanilla-JS single-file page; the baked Vue build (Vue
|
||
// admin-panel task) needs neither: `vite build` emits index.html with
|
||
// only an external `<script type="module" src="/admin/assets/...">` and
|
||
// an external `<link rel="stylesheet">` — no inline script or style tag
|
||
// of any kind (vite.config.ts's `build.modulePreload.polyfill: false`
|
||
// specifically removes the one inline bootstrap Vite would otherwise add
|
||
// for legacy-browser module-preload support). `script-src`/`style-src`
|
||
// therefore tighten to 'self', matching every hashed asset's own origin.
|
||
// `img-src` adds `data:` for index.html's inlined favicon (a `data:` URI,
|
||
// not a separate asset route — see webui/index.html's own comment) —
|
||
// the only image this page ever loads. connect-src stays 'self': the
|
||
// panel's own fetch calls to /admin/api/* are the only network activity
|
||
// it ever performs. frame-ancestors/base-uri/form-action stay '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 <base> href hijacked to retarget a
|
||
// relative script/fetch URL, and its auth form's own submit handler
|
||
// already intercepts and cancels the real submit, so form-action has
|
||
// nothing legitimate to allow.
|
||
const adminCSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; 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 admin dashboard's
|
||
// routes: the page, any hashed asset under adminAssetsPathPrefix, or one
|
||
// of the /admin/api/* JSON routes. adminEventsPath (F3, v0.3 dashboard
|
||
// task) is defined in events.go, alongside the rest of that feature's own
|
||
// constants; adminUsageSeriesPath/adminUsageTotalsPath/
|
||
// adminPerformancePath (stats_read.go), adminCatalogPath/
|
||
// adminConsumersPath (admin_catalog.go), and adminConfigPath
|
||
// (admin_config.go) — admin dashboard redesign, WP-B — follow the same
|
||
// convention.
|
||
func isAdminPath(path string) bool {
|
||
switch path {
|
||
case adminPagePath, adminOverviewPath, adminUsagePath, adminUsageHistoryPath, adminUsageModelsPath,
|
||
adminUsageSeriesPath, adminUsageTotalsPath, adminPerformancePath,
|
||
adminCatalogPath, adminConsumersPath, adminConfigPath,
|
||
adminTargetsPath, adminEventsPath:
|
||
return true
|
||
}
|
||
return strings.HasPrefix(path, adminAssetsPathPrefix)
|
||
}
|
||
|
||
// handleAdmin is ServeHTTP's single entry point for every /admin* route
|
||
// (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. Every hashed asset
|
||
// under adminAssetsPathPrefix is unauthenticated for the same reason: the
|
||
// browser must load the page's own script/style before it can ever send
|
||
// an x-api-key header, and the built JS/CSS is not sensitive (it is
|
||
// exactly what `vite build` emits from webui/, publicly inspectable by
|
||
// any admin dashboard user already). adminEnabled(g.cfg) still governs
|
||
// whether any /admin* path is reachable at all (disabled falls through to
|
||
// 404, unchanged).
|
||
func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) {
|
||
switch {
|
||
case r.URL.Path == adminPagePath:
|
||
g.serveAdminPage(sw)
|
||
case strings.HasPrefix(r.URL.Path, adminAssetsPathPrefix):
|
||
g.serveAdminAsset(sw, strings.TrimPrefix(r.URL.Path, adminAssetsPathPrefix))
|
||
default:
|
||
g.handleAdminAPI(sw, r)
|
||
}
|
||
}
|
||
|
||
// handleAdminAPI is the gate for the /admin/api/* JSON routes, applying
|
||
// spec §4's gate order: unauthenticated → 401, authenticated non-admin →
|
||
// 403, admin → serve.
|
||
//
|
||
// None of these routes call checkAndCount (operator directive: progress
|
||
// ledger, 2026-08-20 — "admin requests must NOT touch req/min, req/day
|
||
// statistics"): admin traffic must never appear in usage statistics,
|
||
// which exist to measure real LLM traffic only. GET /admin/api/usage/
|
||
// history was built this way from the start (v0.2 data-layer task); this
|
||
// change extends the same treatment to GET /admin/api/overview and GET
|
||
// /admin/api/usage, which previously counted like any other authenticated
|
||
// route (spec §4 amended accordingly); GET /admin/api/targets (Feature B,
|
||
// v0.21) is built the same way from the start too. An admin's own req/min
|
||
// or req/day limit, if configured, is therefore never enforced against
|
||
// admin-route traffic either — the accepted trade-off the operator
|
||
// directive names: these are admin-gated, cheap reads, and an admin
|
||
// holder polling the dashboard aggressively is a self-inflicted, not a
|
||
// shared, resource cost.
|
||
func (g *Gateway) handleAdminAPI(sw *statusTrackingWriter, r *http.Request) {
|
||
u, _, 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
|
||
}
|
||
|
||
switch r.URL.Path {
|
||
case adminOverviewPath:
|
||
g.serveAdminOverview(sw)
|
||
case adminUsagePath:
|
||
g.serveAdminUsage(sw)
|
||
case adminUsageHistoryPath:
|
||
g.serveAdminUsageHistory(sw, r)
|
||
case adminUsageModelsPath:
|
||
g.serveAdminUsageModels(sw, r)
|
||
case adminUsageSeriesPath:
|
||
g.serveAdminUsageSeries(sw, r)
|
||
case adminUsageTotalsPath:
|
||
g.serveAdminUsageTotals(sw, r)
|
||
case adminPerformancePath:
|
||
g.serveAdminPerformance(sw, r)
|
||
case adminCatalogPath:
|
||
g.serveAdminCatalog(sw)
|
||
case adminConsumersPath:
|
||
g.serveAdminConsumers(sw)
|
||
case adminConfigPath:
|
||
g.serveAdminConfig(sw)
|
||
case adminTargetsPath:
|
||
g.serveAdminTargets(sw)
|
||
case adminEventsPath:
|
||
g.serveAdminEvents(sw, r)
|
||
}
|
||
}
|
||
|
||
// serveAdminPage writes the built Vue app's index.html
|
||
// (admin_assets_gen.go's adminIndexHTML, generated by webui/generate.mjs)
|
||
// 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)
|
||
_, _ = w.Write(adminIndexHTML)
|
||
}
|
||
|
||
// serveAdminAsset writes one hashed static asset from adminAssets
|
||
// (admin_assets_gen.go, generated by webui/generate.mjs) — name is the
|
||
// path with adminAssetsPathPrefix already stripped (handleAdmin). An
|
||
// unrecognized name (a stale bookmark, a probe) is a 404 in the same
|
||
// OpenAI-shaped error envelope every other unknown route on this gateway
|
||
// returns, not a bare empty body.
|
||
//
|
||
// Cache-Control is long-lived and `immutable`: every filename is content-
|
||
// hashed by the Vite build, so a given name's bytes never change — a
|
||
// config change or plugin upgrade that alters the built output always
|
||
// produces new filenames, never mutates one this browser may have cached.
|
||
// X-Content-Type-Options: nosniff matches the JSON routes' own header
|
||
// (setAdminJSONHeaders) — the declared Content-Type must never be
|
||
// second-guessed by the browser's MIME sniffer. Content-Security-Policy
|
||
// carries adminCSP too (review sweep): a browser only enforces CSP
|
||
// against the top-level document that names it, so this header governs
|
||
// nothing for a JS/CSS response fetched as a sub-resource of GET
|
||
// /admin — but a browser navigated STRAIGHT to an asset URL (a pasted
|
||
// link, a bookmark) treats that response as its own top-level document,
|
||
// and every other admin route already sends the identical header, so
|
||
// there is no reason for this one route to be the exception.
|
||
func (g *Gateway) serveAdminAsset(w http.ResponseWriter, name string) {
|
||
asset, ok := adminAssets[name]
|
||
if !ok {
|
||
writeOAIError(w, http.StatusNotFound, "invalid_request_error", "unknown admin asset")
|
||
return
|
||
}
|
||
w.Header().Set("Content-Type", asset.contentType)
|
||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||
w.Header().Set("Content-Security-Policy", adminCSP)
|
||
_, _ = w.Write(asset.body)
|
||
}
|
||
|
||
// 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 returns unparseableBaseURLPlaceholder
|
||
// instead (P5 fix, admin dashboard redesign verify round: returning raw
|
||
// unchanged here defeated this whole function's purpose the moment a
|
||
// misconfigured baseUrl failed to parse, and url.Parse fails far more
|
||
// permissively than "not a URL at all" suggests — a password containing
|
||
// an unescaped '/' reads as an invalid port on the host, and a malformed
|
||
// '%' escape anywhere in the string is also a parse error; either way
|
||
// whatever credential was embedded survived verbatim in BOTH GET
|
||
// /admin/api/config's redaction, admin_config.go, and GET /admin/api/
|
||
// overview's provider view). A value that fails to parse has nothing
|
||
// structured left to strip selectively, so the only safe answer is to
|
||
// withhold it entirely.
|
||
func sanitizeBaseURL(raw string) string {
|
||
u, err := url.Parse(raw)
|
||
if err != nil {
|
||
return unparseableBaseURLPlaceholder
|
||
}
|
||
u.User = nil
|
||
u.RawQuery = ""
|
||
u.Fragment = ""
|
||
return u.String()
|
||
}
|
||
|
||
// unparseableBaseURLPlaceholder is what sanitizeBaseURL (above) returns in
|
||
// place of a raw value that failed url.Parse — named so every call site
|
||
// and test refers to the identical literal instead of restating it.
|
||
const unparseableBaseURLPlaceholder = "[unparseable url]"
|
||
|
||
// 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.
|
||
//
|
||
// It scrubs rawBaseURL's credentials in BOTH forms a real error from a
|
||
// call against rawBaseURL can carry them (security audit run-1, finding
|
||
// F-2): the verbatim URL, and net/http's own password-masked rewrite of
|
||
// it. Go's net/http builds a *url.Error via its unexported stripPassword
|
||
// (net/http/client.go) whenever the dialed URL's userinfo reports a
|
||
// password SET — which net/url's parseAuthority makes true for ANY
|
||
// userinfo containing a colon, including the token-as-username form
|
||
// "https://TOKEN:@host" whose password is empty. stripPassword rewrites
|
||
// "user:pass@" to "user:***@" IN THE URL BEFORE the error string is ever
|
||
// built, so for such a URL the error text never contains rawBaseURL
|
||
// verbatim; a verbatim-only match then finds nothing to scrub, leaving
|
||
// the USERNAME (commonly the token itself) and any query-string
|
||
// credential riding the same URL in place.
|
||
//
|
||
// sanitizeTargetErr (target_health.go) delegates here rather than
|
||
// carrying its own copy of this logic: the private copy it used to hold
|
||
// is exactly what left this provider-side path unprotected between the
|
||
// two changes that introduced them, so the two must never diverge again.
|
||
//
|
||
// Still best-effort for URLs OTHER than rawBaseURL: an error naming a
|
||
// redirect target is not caught. Rejecting a credential-bearing baseUrl
|
||
// at construction (providers.go) is the only complete fix; this is the
|
||
// containment that changes no behavior.
|
||
func sanitizeProviderErr(lastErr, rawBaseURL string) string {
|
||
if lastErr == "" || rawBaseURL == "" {
|
||
return lastErr
|
||
}
|
||
out := replaceURLForm(lastErr, rawBaseURL)
|
||
u, parseErr := url.Parse(rawBaseURL)
|
||
if parseErr != nil || u.User == nil {
|
||
return out
|
||
}
|
||
if _, hasPassword := u.User.Password(); !hasPassword {
|
||
// No password set means net/http passed the URL through
|
||
// unmasked, so the verbatim pass above already matched it.
|
||
return out
|
||
}
|
||
// net/http's exact stripPassword output, mirrored call for call.
|
||
masked := strings.Replace(u.String(), u.User.String()+"@", u.User.Username()+":***@", 1)
|
||
return replaceURLForm(out, masked)
|
||
}
|
||
|
||
// replaceURLForm replaces every occurrence of one rendering of a URL in
|
||
// msg with sanitizeBaseURL's cleaned form of that same rendering.
|
||
func replaceURLForm(msg, form string) string {
|
||
if form == "" || !strings.Contains(msg, form) {
|
||
return msg
|
||
}
|
||
return strings.ReplaceAll(msg, form, sanitizeBaseURL(form))
|
||
}
|
||
|
||
// 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 {
|
||
LastRefresh time.Time `json:"lastRefresh"`
|
||
// OpenUntil is when this provider's breaker will next attempt a
|
||
// half-open probe (feat/provider-health). The zero time.Time when
|
||
// HealthState is not "open". Grouped with LastRefresh above (both
|
||
// time.Time) for fieldalignment — see providerState's own doc
|
||
// comment for the convention this struct follows.
|
||
OpenUntil time.Time `json:"openUntil"`
|
||
// ModelMeta is this provider's resolved per-model metadata (feature
|
||
// v0.23: context window, per-token cost — resolveModelMeta,
|
||
// modelmeta.go), keyed by upstream model id. One entry per id in
|
||
// Models, unconditionally — never nil, mirroring ModelRates' own
|
||
// "never nil" convention below — so the dashboard's provider
|
||
// accordion can render a context/cost chip beside each model without
|
||
// a second round trip. An individual entry's own fields are omitted
|
||
// (not zeroed) when unknown; see adminModelMetaView's own doc
|
||
// comment.
|
||
ModelMeta map[string]adminModelMetaView `json:"modelMeta"`
|
||
// ModelRates is this provider's per-model breakdown of the Attempts*/
|
||
// Failures* counters below, keyed by upstream model id (Feature A,
|
||
// v0.22) — every entry in Models gets one, even a model with zero
|
||
// attempts, so the Providers tab's provider accordion can decide
|
||
// "degraded, show a badge" per model without a second round trip.
|
||
// Never nil, mirroring Models' own "never nil" convention below —
|
||
// marshals as "{}" for a provider with no known models yet, not
|
||
// omitted.
|
||
ModelRates map[string]adminModelRateView `json:"modelRates"`
|
||
// Latency is a compact per-stream ("streaming"/"non-streaming")
|
||
// average TTFB/duration summary for this provider (feat: instrument
|
||
// upstream latency), sourced from the SAME in-process g.latency
|
||
// accumulator /metrics reads (metrics.go). Averages only — never a
|
||
// full histogram or a per-model breakdown — to keep this already-
|
||
// ~113KB, 5s-polled response from growing meaningfully; see
|
||
// buildAdminLatencyViews' own doc comment. nil (omitted) for a
|
||
// provider with no observations yet, or for a deployment with metrics
|
||
// collection gated off entirely. Grouped here with the other map
|
||
// fields above, not declared near AttemptsDay/ModelCount below,
|
||
// because it is a pointer-shaped field (a map header) and this
|
||
// struct's fields are ordered pointer-shaped-first, then plain
|
||
// scalars (int64/int/bool) last — fieldalignment (govet, enabled via
|
||
// this repo's global golangci-lint config) flags a pointer field
|
||
// declared after the scalar block as costing extra GC pointer-scan
|
||
// bytes. See timeout.go's watchdogBody for the identical convention.
|
||
Latency map[string]adminLatencyView `json:"latency,omitempty"`
|
||
// Provenance summarizes non-reported usage accounting for this
|
||
// provider (feat: expose token-accounting provenance) — keyed by
|
||
// provenance kind, "estimated" or "unbilled" only (see
|
||
// adminProvenanceView's own doc comment for why "reported" is never a
|
||
// key here). Sourced from the SAME in-process g.provenance
|
||
// accumulator /metrics reads (metrics.go). nil (omitted) for a
|
||
// provider with no estimated/unbilled outcome yet, or for a
|
||
// deployment with metrics collection gated off entirely — mirroring
|
||
// Latency's identical "nil means no observations yet" convention
|
||
// immediately above.
|
||
Provenance map[string]adminProvenanceView `json:"provenance,omitempty"`
|
||
Type string `json:"type"`
|
||
BaseURL string `json:"baseUrl"`
|
||
LastErr string `json:"lastErr,omitempty"`
|
||
// HealthState is this provider's discovery circuit breaker state
|
||
// (feat/provider-health): "closed" (normal), "open" (backing off
|
||
// after repeated discovery failures — maybeRefresh skips it until
|
||
// OpenUntil above), or "half-open" (a probe refresh is in flight,
|
||
// deciding whether to close the breaker again). A provider with
|
||
// discovery disabled, or one that has never failed a refresh, always
|
||
// reads "closed" — it has no health signal that could trip the
|
||
// breaker.
|
||
HealthState string `json:"healthState"`
|
||
Name string `json:"name"`
|
||
// Models is the sorted explicit∪discovered model id set
|
||
// (registry.go's providerSnapshot.models — provider-model-accordion
|
||
// task, Vue admin panel): the dashboard's expandable provider row.
|
||
// Never nil for a real provider — even zero known models marshals as
|
||
// "[]", not omitted, so the dashboard can tell "no models yet" apart
|
||
// from a field a stale client build does not know how to read.
|
||
Models []string `json:"models"`
|
||
// AttemptsDay/FailuresDay/AttemptsMinute/FailuresMinute are this
|
||
// provider's current-window upstream-attempt counters (Feature A,
|
||
// v0.22 — see limiter.recordProviderAttempt, limits.go): every
|
||
// upstream HTTP attempt increments Attempts*; only a provider-fault
|
||
// outcome (isTransient's classification, retry.go) additionally
|
||
// increments Failures*. The Providers tab's success-rate badge is
|
||
// (AttemptsDay-FailuresDay)/AttemptsDay; AttemptsMinute/FailuresMinute
|
||
// exist only for the badge's "right now" title-attribute detail — no
|
||
// day-window figure alone tells an operator whether a spike is still
|
||
// happening. Both stay 0 for a provider with no traffic yet, which the
|
||
// webui renders as a distinct "no traffic" state rather than a
|
||
// (misleadingly perfect) 100% badge.
|
||
AttemptsDay int64 `json:"attemptsDay"`
|
||
FailuresDay int64 `json:"failuresDay"`
|
||
AttemptsMinute int64 `json:"attemptsMinute"`
|
||
FailuresMinute int64 `json:"failuresMinute"`
|
||
ModelCount int `json:"modelCount"`
|
||
// DiscoveryEnabled mirrors providerSnapshot.discoveryEnabled (Feature
|
||
// B, v0.22): the Providers tab uses it to tell a provider whose
|
||
// discovery is off apart from one that is on but has not completed its
|
||
// first refresh yet — both otherwise show the same zero LastRefresh.
|
||
// See registry.go's providerSnapshot.discoveryEnabled doc comment.
|
||
DiscoveryEnabled bool `json:"discoveryEnabled"`
|
||
}
|
||
|
||
// adminModelRateView is one upstream model's current-window attempt/
|
||
// failure counters within its provider — adminProviderView.ModelRates'
|
||
// value type (Feature A, v0.22). Day window only, no AttemptsMinute/
|
||
// FailuresMinute (SHOULD-2, v0.22 review round — removed, not merely
|
||
// left unpopulated): a dashboard with N configured models was paying N
|
||
// wasted minute-window store reads on every 5s poll for a figure that
|
||
// only ever fed a badge's title text, never its own displayed tier — see
|
||
// limits.go's providerCounterKeys/providerScopeKeyCount for the read-side
|
||
// half of this same ruling. The Providers tab's per-model badge falls
|
||
// back to a day-window-only title when these are absent; see webui's
|
||
// lib/provider-rate.ts (dayRateTitle) and ProviderRateBadge.vue.
|
||
type adminModelRateView struct {
|
||
AttemptsDay int64 `json:"attemptsDay"`
|
||
FailuresDay int64 `json:"failuresDay"`
|
||
}
|
||
|
||
// adminLatencyView is one provider's one stream-state's compact latency
|
||
// summary (feat: instrument upstream latency) — adminProviderView.
|
||
// Latency's value type. Plain averages (sum/count from the same
|
||
// latencySnapshot /metrics reads, metrics.go), never a percentile: no
|
||
// interpolation-correctness question to get wrong, and "compact" (the
|
||
// task brief's own word for this admin surface) rules out shipping a
|
||
// full bucket set here anyway — that detail belongs on /metrics.
|
||
// AvgTTFBMs is 0 (omitted) when no successful read was ever observed
|
||
// for this stream state (latencySnapshot.ttfbCount == 0) — never
|
||
// fabricated from AvgDurationMs, which is a materially different
|
||
// number for a genuine stream (see llmgateway_upstream_ttfb_seconds'
|
||
// own HELP text, metrics.go, for why the two must never be conflated).
|
||
type adminLatencyView struct {
|
||
AvgTTFBMs float64 `json:"avgTtfbMs,omitempty"`
|
||
AvgDurationMs float64 `json:"avgDurationMs,omitempty"`
|
||
Count int64 `json:"count,omitempty"`
|
||
}
|
||
|
||
// buildAdminLatencyViews groups snaps (g.latency.snapshot(), metrics.go)
|
||
// by provider, discarding the opt-in model dimension entirely (task
|
||
// brief: "compact... do not bloat" — GET /admin/api/overview already
|
||
// sits around 113 KB and is polled every 5s; a full per-model latency
|
||
// breakdown belongs on /metrics, not here). The outer map is keyed by
|
||
// provider name, the inner by "streaming"/"non-streaming"; a provider
|
||
// with no observations at all is simply absent from the outer map, so
|
||
// adminOverviewResponse's own lookup (buildAdminOverview, below) yields
|
||
// nil for it — adminProviderView.Latency's own documented "nil means no
|
||
// observations yet".
|
||
func buildAdminLatencyViews(snaps []latencySnapshot) map[string]map[string]adminLatencyView {
|
||
out := make(map[string]map[string]adminLatencyView)
|
||
for _, s := range snaps {
|
||
if s.key.model != "" || s.durationCount == 0 {
|
||
continue
|
||
}
|
||
streamKey := "non-streaming"
|
||
if s.key.streaming {
|
||
streamKey = "streaming"
|
||
}
|
||
v := adminLatencyView{
|
||
AvgDurationMs: s.durationSum / float64(s.durationCount) * 1000,
|
||
Count: s.durationCount,
|
||
}
|
||
if s.ttfbCount > 0 {
|
||
v.AvgTTFBMs = s.ttfbSum / float64(s.ttfbCount) * 1000
|
||
}
|
||
if out[s.key.provider] == nil {
|
||
out[s.key.provider] = make(map[string]adminLatencyView)
|
||
}
|
||
out[s.key.provider][streamKey] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// adminProvenanceView is one provider's one non-reported provenance
|
||
// kind's compact usage-accounting summary (feat: expose token-accounting
|
||
// provenance) — adminProviderView.Provenance's value type, keyed by
|
||
// provenance kind ("estimated" or "unbilled" — provenanceEstimated/
|
||
// provenanceUnbilled, metrics.go) in adminProviderView.Provenance's outer
|
||
// map. "reported" is deliberately never a key here: it is the default,
|
||
// healthy case AttemptsDay/FailuresDay above already partially describe,
|
||
// and duplicating its own count would not answer any operator question
|
||
// this compact surface (buildAdminLatencyViews' own "compact, no
|
||
// per-model breakdown" ruling, GET /admin/api/overview's ~113KB/5s-poll
|
||
// budget) is not already better placed to answer — the full reported/
|
||
// estimated/unbilled breakdown, by request AND by token, lives on
|
||
// /metrics (llmgateway_usage_provenance_requests_total/-_tokens_total)
|
||
// for an operator who wants the precise fraction.
|
||
type adminProvenanceView struct {
|
||
Requests int64 `json:"requests"`
|
||
Tokens int64 `json:"tokens"`
|
||
}
|
||
|
||
// buildAdminProvenanceViews groups snaps (g.provenance.snapshot(),
|
||
// metrics.go) by provider, discarding the "reported" provenance entirely
|
||
// (adminProvenanceView's own doc comment) — mirroring
|
||
// buildAdminLatencyViews' identical "compact, provider-keyed outer map"
|
||
// shape immediately above. A provider with no estimated/unbilled
|
||
// observations at all is simply absent from the outer map, so
|
||
// adminOverviewResponse's own lookup (buildAdminOverview, below) yields
|
||
// nil for it — adminProviderView.Provenance's own documented "nil means
|
||
// no estimated/unbilled accounting yet".
|
||
func buildAdminProvenanceViews(snaps []provenanceSnapshot) map[string]map[string]adminProvenanceView {
|
||
out := make(map[string]map[string]adminProvenanceView)
|
||
for _, s := range snaps {
|
||
if s.key.provenance == provenanceReported {
|
||
continue
|
||
}
|
||
if out[s.key.provider] == nil {
|
||
out[s.key.provider] = make(map[string]adminProvenanceView)
|
||
}
|
||
out[s.key.provider][s.key.provenance] = adminProvenanceView{Requests: s.requests, Tokens: s.tokens}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// adminModelMetaView is one upstream model's resolved metadata (feature
|
||
// v0.23: context window, per-token cost) within its provider —
|
||
// adminProviderView.ModelMeta's value type. Every field is a pointer,
|
||
// not a plain int/float64: a plain field's "omitempty" cannot tell a
|
||
// genuinely unknown value apart from an explicit zero (a free model's
|
||
// cost IS 0, and known), so a nil pointer (omitted from the JSON
|
||
// response) means "unknown" and a non-nil pointer — even one pointing at
|
||
// 0 — means "known", mirroring resolvedModelMeta's own ContextKnown/
|
||
// CostKnown bools (modelmeta.go) one for one.
|
||
type adminModelMetaView struct {
|
||
ContextTokens *int `json:"contextTokens,omitempty"`
|
||
InputPerMTokUSD *float64 `json:"inputPerMTokUsd,omitempty"`
|
||
OutputPerMTokUSD *float64 `json:"outputPerMTokUsd,omitempty"`
|
||
}
|
||
|
||
// buildAdminModelMetaView converts resolveModelMeta's result into the
|
||
// admin API's pointer-based "known vs. unknown" JSON shape (see
|
||
// adminModelMetaView's own doc comment for why plain zero values cannot
|
||
// do this).
|
||
func buildAdminModelMetaView(meta resolvedModelMeta) adminModelMetaView {
|
||
var v adminModelMetaView
|
||
if meta.ContextKnown {
|
||
ctx := meta.ContextTokens
|
||
v.ContextTokens = &ctx
|
||
}
|
||
if meta.CostKnown {
|
||
in := microUSDPerMTokToUSD(meta.InputCostPerMTokMicroUSD)
|
||
out := microUSDPerMTokToUSD(meta.OutputCostPerMTokMicroUSD)
|
||
v.InputPerMTokUSD = &in
|
||
v.OutputPerMTokUSD = &out
|
||
}
|
||
return v
|
||
}
|
||
|
||
// 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 {
|
||
// ModelMeta is this alias's own resolved metadata (feature v0.23,
|
||
// hover-detail refinement): the alias's INHERITED metadata from its
|
||
// resolved target, unless the alias itself carries its own modelMeta
|
||
// override (resolveMetaForAliasName, registry.go — same
|
||
// alias-inheritance rule listFor's own alias entries use for GET
|
||
// /v1/models). All-unknown (every field omitted) when the alias's
|
||
// target does not resolve to any known model yet.
|
||
ModelMeta adminModelMetaView `json:"modelMeta"`
|
||
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"`
|
||
Replica string `json:"replica"` // F4/F9, v0.3 dashboard task: os.Hostname() -> $HOSTNAME -> "unknown" (replicaID, llmgateway.go)
|
||
Instance string `json:"instance"`
|
||
Warnings []string `json:"warnings"` // F10, v0.3 dashboard task: collected construction warnings, never nil
|
||
Providers []adminProviderView `json:"providers"`
|
||
Groups []adminGroupView `json:"groups"`
|
||
Aliases []adminAliasView `json:"aliases"`
|
||
Redis adminRedisView `json:"redis"`
|
||
Cache adminCacheView `json:"cache"`
|
||
Retry adminRetryView `json:"retry"`
|
||
// Features (admin dashboard redesign, WP-B) reports which of the
|
||
// new opt-in/always-on-with-admin statistics families this Gateway
|
||
// currently exposes — see adminFeaturesView's own doc comment.
|
||
Features adminFeaturesView `json:"features"`
|
||
// Pricing summarizes the live catalog's billing-price-source mix
|
||
// (adminPricingSummaryView's own doc comment) — a one-glance "how
|
||
// much of my fleet is actually priced" figure for the Home page,
|
||
// without a second round trip to GET /admin/api/catalog.
|
||
Pricing adminPricingSummaryView `json:"pricing"`
|
||
// WarningsDropped counts construction warnings that did not fit within
|
||
// configWarningsCap (logger.go) — omitted (0) means nothing was dropped.
|
||
WarningsDropped int `json:"warningsDropped,omitempty"`
|
||
}
|
||
|
||
// adminFeaturesView reports which of the admin dashboard redesign's new
|
||
// statistics families this Gateway currently exposes — GET
|
||
// /admin/api/overview, GET /admin/api/config, and every stats_read.go
|
||
// endpoint's own 404 gate all echo the SAME struct, built by the SAME
|
||
// buildAdminFeatures (below), so the webui can gate a whole page section
|
||
// on one flag without guessing at a second endpoint's own availability.
|
||
//
|
||
// UserModelStats and LatencyStats are opt-in (DECISIONS Q4: both default
|
||
// OFF), read from Config.Admin.Stats. CacheStats, LastSeen and Failover
|
||
// are "always-on-with-admin" (DECISIONS: zero/near-zero cost, no new
|
||
// round trips on the request path) — CacheStats additionally requires
|
||
// the response cache itself to be configured (nothing to report
|
||
// otherwise); LastSeen and Failover need only adminEnabled, and are
|
||
// reported true whenever this endpoint answers at all, kept as their own
|
||
// explicit flags (rather than assumed true) so a future version gate
|
||
// never has to change this struct's shape to turn one off.
|
||
type adminFeaturesView struct {
|
||
UserModelStats bool `json:"userModelStats"`
|
||
LatencyStats bool `json:"latencyStats"`
|
||
CacheStats bool `json:"cacheStats"`
|
||
LastSeen bool `json:"lastSeen"`
|
||
Failover bool `json:"failover"`
|
||
}
|
||
|
||
// buildAdminFeatures reads Config.Admin.Stats (nil-safe: an admin block
|
||
// with no stats sub-block, or predating this feature, means both opt-in
|
||
// flags stay off — DECISIONS Q4's own defaults) and g.cache's presence to
|
||
// assemble adminFeaturesView. Shared by GET /admin/api/overview
|
||
// (buildAdminOverview, below), GET /admin/api/config (admin_config.go),
|
||
// and every stats_read.go endpoint's own feature-gated 404 check, so
|
||
// they can never disagree about which families are live.
|
||
func (g *Gateway) buildAdminFeatures() adminFeaturesView {
|
||
var userModel, latency bool
|
||
if g.cfg.Admin != nil && g.cfg.Admin.Stats != nil {
|
||
userModel = g.cfg.Admin.Stats.UserModel
|
||
latency = g.cfg.Admin.Stats.Latency
|
||
}
|
||
return adminFeaturesView{
|
||
UserModelStats: userModel,
|
||
LatencyStats: latency,
|
||
CacheStats: g.cache != nil,
|
||
LastSeen: true,
|
||
Failover: true,
|
||
}
|
||
}
|
||
|
||
// adminPricingSummaryView counts the live model catalog's rows by
|
||
// billing price source (billingPriceSource, pricing.go) — Override,
|
||
// Builtin, Litellm, Free and Unpriced sum to the catalog's total model
|
||
// count. GET /admin/api/overview's own Pricing field (buildAdminOverview,
|
||
// below); GET /admin/api/catalog's per-model PriceSource field
|
||
// (admin_catalog.go) is this same classification, unaggregated.
|
||
type adminPricingSummaryView struct {
|
||
Override int `json:"override"`
|
||
Builtin int `json:"builtin"`
|
||
Litellm int `json:"litellm"`
|
||
Free int `json:"free"`
|
||
Unpriced int `json:"unpriced"`
|
||
}
|
||
|
||
// buildAdminPricingSummary counts every catalogued (provider, model)
|
||
// pair in snaps by billingPriceSource's own result — the SAME resolution
|
||
// GET /admin/api/catalog applies per model (admin_catalog.go), summed
|
||
// here rather than read from a second registry snapshot, so the two
|
||
// views can never disagree about the fleet-wide mix even though they run
|
||
// against independently-taken snapshots.
|
||
func buildAdminPricingSummary(snaps []providerSnapshot, overrides map[string]*ModelPricing, meta map[string]*ModelMetaConfig) adminPricingSummaryView {
|
||
var summary adminPricingSummaryView
|
||
for _, s := range snaps {
|
||
for _, model := range s.models {
|
||
canonical := providerModelScopeID(s.name, model)
|
||
switch source, _ := billingPriceSource(canonical, model, overrides, meta); source {
|
||
case priceSourceOverride:
|
||
summary.Override++
|
||
case priceSourceBuiltin:
|
||
summary.Builtin++
|
||
case priceSourceLitellm:
|
||
summary.Litellm++
|
||
case priceSourceFree:
|
||
summary.Free++
|
||
default:
|
||
summary.Unpriced++
|
||
}
|
||
}
|
||
}
|
||
return summary
|
||
}
|
||
|
||
// 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()
|
||
|
||
// Feature A (v0.22): ONE batched limiter read for every provider's and
|
||
// every (provider, model) pair's current attempt/failure counters —
|
||
// scopes built here in two fixed passes (every provider, then every
|
||
// provider's models, in snaps' own order), so providerUsage's flat
|
||
// result slices back by plain index: the first len(snaps) entries are
|
||
// the provider-level counters, in order; the rest are the per-model
|
||
// counters, grouped by provider in the same order and, within a
|
||
// provider, in s.models' own order — exactly how the loop below
|
||
// consumes them.
|
||
scopes := make([]limitScope, 0, len(snaps))
|
||
for _, s := range snaps {
|
||
scopes = append(scopes, limitScope{kind: kindProvider, id: s.name})
|
||
}
|
||
for _, s := range snaps {
|
||
for _, model := range s.models {
|
||
scopes = append(scopes, limitScope{kind: kindProviderModel, id: providerModelScopeID(s.name, model)})
|
||
}
|
||
}
|
||
allCounters := g.limiter.providerUsage(scopes)
|
||
providerLevelCounters := allCounters[:len(snaps)]
|
||
modelCounters := allCounters[len(snaps):]
|
||
|
||
// feat: instrument upstream latency — one g.latency.snapshot() call
|
||
// for the whole response, mirroring providerUsage's own one-batched-
|
||
// read-for-every-provider shape above, not a per-provider read.
|
||
latencyViews := buildAdminLatencyViews(g.latency.snapshot())
|
||
// feat: expose token-accounting provenance — one g.provenance.
|
||
// snapshot() call for the whole response, mirroring g.latency.
|
||
// snapshot()'s own one-batched-read-for-every-provider shape above.
|
||
provenanceViews := buildAdminProvenanceViews(g.provenance.snapshot())
|
||
|
||
providers := make([]adminProviderView, len(snaps))
|
||
mi := 0
|
||
for i, s := range snaps {
|
||
pc := providerLevelCounters[i]
|
||
modelRates := make(map[string]adminModelRateView, len(s.models))
|
||
modelMeta := make(map[string]adminModelMetaView, len(s.models))
|
||
for _, model := range s.models {
|
||
mc := modelCounters[mi]
|
||
mi++
|
||
modelRates[model] = adminModelRateView{
|
||
AttemptsDay: mc.attemptsDay,
|
||
FailuresDay: mc.failuresDay,
|
||
}
|
||
modelMeta[model] = buildAdminModelMetaView(g.registry.resolveMetaFor(s.name, model))
|
||
}
|
||
providers[i] = adminProviderView{
|
||
Name: s.name,
|
||
Type: s.typeName,
|
||
BaseURL: sanitizeBaseURL(s.baseURL),
|
||
Models: s.models,
|
||
ModelCount: s.modelCount,
|
||
LastRefresh: s.lastRefresh,
|
||
LastErr: sanitizeProviderErr(s.lastErr, s.baseURL),
|
||
DiscoveryEnabled: s.discoveryEnabled,
|
||
HealthState: s.health.String(),
|
||
OpenUntil: s.openUntil,
|
||
AttemptsDay: pc.attemptsDay,
|
||
FailuresDay: pc.failuresDay,
|
||
AttemptsMinute: pc.attemptsMinute,
|
||
FailuresMinute: pc.failuresMinute,
|
||
ModelRates: modelRates,
|
||
ModelMeta: modelMeta,
|
||
Latency: latencyViews[s.name],
|
||
Provenance: provenanceViews[s.name],
|
||
}
|
||
}
|
||
|
||
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}
|
||
}
|
||
|
||
aliases := g.buildAdminAliasViews()
|
||
|
||
warnings, warningsDropped := g.configWarningsSnapshot()
|
||
return adminOverviewResponse{
|
||
Providers: providers,
|
||
Redis: adminRedisView{Configured: configured, LastErr: redisLastErr, LastErrAt: redisLastErrAt},
|
||
Cache: adminCacheView{Enabled: cacheEnabled, TTL: ttl},
|
||
Retry: retryView,
|
||
Groups: groups,
|
||
Aliases: aliases,
|
||
Features: g.buildAdminFeatures(),
|
||
Pricing: buildAdminPricingSummary(snaps, g.cfg.Pricing, g.cfg.ModelMeta),
|
||
Version: pluginVersion,
|
||
Replica: g.replica,
|
||
Instance: g.name,
|
||
Warnings: warnings,
|
||
WarningsDropped: warningsDropped,
|
||
}
|
||
}
|
||
|
||
// buildAdminAliasViews returns every configured model alias's read-only
|
||
// view (adminAliasView) — shared by GET /admin/api/overview's own
|
||
// Aliases field (buildAdminOverview, above) and GET /admin/api/catalog's
|
||
// identical field (admin_catalog.go), so the two can never list a
|
||
// different alias set or disagree about one alias's resolved metadata.
|
||
func (g *Gateway) buildAdminAliasViews() []adminAliasView {
|
||
aliasSnaps := g.registry.aliasSnapshot()
|
||
aliases := make([]adminAliasView, len(aliasSnaps))
|
||
for i, a := range aliasSnaps {
|
||
aliases[i] = adminAliasView{
|
||
Alias: a.Alias,
|
||
Target: a.Target,
|
||
ModelMeta: buildAdminModelMetaView(g.registry.resolveMetaForAliasName(a.Alias)),
|
||
}
|
||
}
|
||
return aliases
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// Providers/Models/MCPServers/Agents (group-access-display task) are set
|
||
// only for a group entry — a user or the total entry never populates them,
|
||
// same convention as GroupName's own "user-only" field above, mirrored.
|
||
// Each carries the group's GroupConfig list exactly as configured: omitted
|
||
// (nil) means every provider/model/MCP server/agent is allowed
|
||
// (group.allowsX's own empty-list-matches-anything contract, auth.go) —
|
||
// this is never resolved/expanded to the full catalog here, so the
|
||
// dashboard can tell "explicitly restricted to these N" apart from
|
||
// "unrestricted" without the payload growing with catalog size.
|
||
type adminUsageEntryView struct {
|
||
Limits *LimitsConfig `json:"limits,omitempty"`
|
||
Kind string `json:"kind"`
|
||
ID string `json:"id"`
|
||
GroupName string `json:"groupName,omitempty"`
|
||
// Groups is the user's full member group list (multi-group support)
|
||
// — set only for a user entry, same "user-only" convention GroupName
|
||
// above already follows. GroupName keeps the first group for
|
||
// back-compat; Groups is the complete membership.
|
||
Groups []string `json:"groups,omitempty"`
|
||
Providers []string `json:"providers,omitempty"`
|
||
Models []string `json:"models,omitempty"`
|
||
MCPServers []string `json:"mcpServers,omitempty"`
|
||
Agents []string `json:"agents,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"`
|
||
// RejectionsPerDay is the fleet-wide count of checkAndCount rejections
|
||
// attributed to this scope today (F4, v0.3 dashboard task) — read via
|
||
// windowKey's metric "rej", window "day" (limits.go's metricRej).
|
||
RejectionsPerDay int64 `json:"rejectionsPerDay"`
|
||
// LastSeen is this scope's most recent admitted-request instant, unix
|
||
// seconds (admin dashboard redesign, WP-B; DECISIONS Q6) — 0
|
||
// (omitted) means never observed, or unknown (a storeDown read).
|
||
// Sourced from scopeUsage.lastSeen (limits.go, WP-A step 1), the
|
||
// usageKeysPerScope's 10th key.
|
||
LastSeen int64 `json:"lastSeen,omitempty"`
|
||
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,
|
||
RejectionsPerDay: su.rejectionsPerDay,
|
||
LastSeen: su.lastSeen,
|
||
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
|
||
// chunkedCurrentUsage (above) reads every one's current-window counters —
|
||
// plus the synthetic total scope's own (v0.2 data-layer task) — in
|
||
// batches of at most adminUsageChunkScopes scopes per storeGetMulti round
|
||
// trip (security audit finding 4, 2026-08-22, capping the v0.2 final
|
||
// review wave's own "fold everything into ONE round trip" design — see
|
||
// adminUsageChunkScopes' own doc comment for why) — users, groups, and the
|
||
// total scope are concatenated into a single scopes slice before
|
||
// chunking, 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.
|
||
// adminUsageChunkScopes bounds how many scopes buildAdminUsage sends to
|
||
// limiter.currentUsage in a single storeGetMulti round trip (security
|
||
// audit finding 4, 2026-08-22). currentUsage's own doc comment (limits.go)
|
||
// already explains why every scope was folded into ONE round trip rather
|
||
// than one per scope — but "one round trip" for the WHOLE catalog means
|
||
// that round trip's own pipeline size, and how long it holds the shared,
|
||
// mutex-guarded Redis connection (respClient, resp.go), now scales with
|
||
// how many users and groups are configured. With 1,000 users that is
|
||
// ~9,000 GETs (usageKeysPerScope=9, F4 v0.3 dashboard task's rej:day
|
||
// counter) in one pipelined call, during which
|
||
// every concurrent checkAndCount from live LLM traffic queues behind the
|
||
// same connection mutex — and the admin dashboard polls this endpoint
|
||
// every 5s. chunkedCurrentUsage (below) keeps each individual round trip's
|
||
// key count bounded, letting live traffic interleave between chunks,
|
||
// while a chunk still batches every scope inside it exactly as before,
|
||
// keeping most of currentUsage's own round-trip-collapsing win. 200
|
||
// scopes (1,600 keys per chunk) comfortably covers the live cluster's 5
|
||
// friends + 4 home users in a single chunk, so today's real deployment
|
||
// sees no behavior change at all — chunking only engages once a
|
||
// deployment's user+group count actually grows past it. One accepted,
|
||
// disclosed side effect: currentUsage's own doc comment notes a transient
|
||
// store error marks every scope in ONE call storeDown together; chunking
|
||
// narrows that blast radius to one chunk's worth of scopes instead of the
|
||
// whole response, which is a strict improvement, not a new risk.
|
||
//
|
||
// Lowered from 200 to 160 (admin dashboard redesign, WP-B) when
|
||
// usageKeysPerScope grew from 9 to 10 for the new lastSeen counter
|
||
// (limits.go, WP-A step 1): 160 scopes * 10 keys/scope = 1,600 keys per
|
||
// chunk, keeping this endpoint's per-round-trip key count at the same
|
||
// order of magnitude this constant has always targeted, rather than
|
||
// letting it grow every time a future per-scope counter is added here.
|
||
const adminUsageChunkScopes = 160
|
||
|
||
// chunkedCurrentUsage calls limiter.currentUsage in batches of at most
|
||
// adminUsageChunkScopes scopes at a time, concatenating the results in
|
||
// the SAME order scopes was given — buildAdminUsage's own slicing of the
|
||
// flat result back into users/groups/total (below) is unaffected by, and
|
||
// unaware of, the chunking underneath. A scopes slice at or under the
|
||
// chunk size makes exactly one call, byte-for-byte what calling
|
||
// currentUsage directly would have done.
|
||
func (g *Gateway) chunkedCurrentUsage(scopes []limitScope) []scopeUsage {
|
||
out := make([]scopeUsage, 0, len(scopes))
|
||
for len(scopes) > 0 {
|
||
n := adminUsageChunkScopes
|
||
if n > len(scopes) {
|
||
n = len(scopes)
|
||
}
|
||
out = append(out, g.limiter.currentUsage(scopes[:n])...)
|
||
scopes = scopes[n:]
|
||
}
|
||
return out
|
||
}
|
||
|
||
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.chunkedCurrentUsage(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
|
||
users[i].Groups = userSummaries[i].groups
|
||
}
|
||
groups := make([]adminUsageEntryView, len(groupUsage))
|
||
for i, su := range groupUsage {
|
||
groups[i] = usageEntryView(su, groupSummaries[i].limits)
|
||
groups[i].Providers = groupSummaries[i].providers
|
||
groups[i].Models = groupSummaries[i].models
|
||
groups[i].MCPServers = groupSummaries[i].mcpServers
|
||
groups[i].Agents = groupSummaries[i].agents
|
||
}
|
||
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}", "model:{provider}/{model}", 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).
|
||
//
|
||
// strings.Cut splits on the FIRST colon only, so a model id that itself
|
||
// contains one (an ":free"-suffixed upstream id, for instance) survives
|
||
// intact in the returned id.
|
||
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" && k != kindModel {
|
||
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, a
|
||
// configured group, or a currently catalogued (provider, model) pair —
|
||
// 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", "group" or
|
||
// kindModel by parseHistoryScope (the total scope's id is never checked
|
||
// against it — totalScopeID always exists).
|
||
//
|
||
// A kindModel id is checked against the live registry, so a model that
|
||
// accumulated usage and was later dropped from the catalog answers 404
|
||
// rather than a series nothing can reach through the UI. That matches
|
||
// buildAdminUsageModels, which enumerates the same catalog: the picker
|
||
// never offers an id this check would then reject.
|
||
func (g *Gateway) scopeExists(kind, id string) bool {
|
||
if kind == kindModel {
|
||
for _, s := range g.registry.snapshot() {
|
||
for _, model := range s.models {
|
||
if providerModelScopeID(s.name, model) == id {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
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})
|
||
}
|
||
|
||
// usageModelsDefaultLimit and usageModelsMaxLimit bound GET
|
||
// /admin/api/usage/models' "limit" parameter — how many ranked models the
|
||
// response carries at most. The default is what the Charts view's own
|
||
// ranking renders without asking for a limit at all.
|
||
const (
|
||
usageModelsDefaultLimit = 20
|
||
usageModelsMaxLimit = 100
|
||
)
|
||
|
||
// parseUsageModelsLimit parses GET /admin/api/usage/models' "limit" query
|
||
// parameter: empty means usageModelsDefaultLimit, otherwise an integer
|
||
// between 1 and usageModelsMaxLimit inclusive. ok is false for anything
|
||
// else, mirroring parseHistorySpan's own validate-before-reading shape.
|
||
func parseUsageModelsLimit(raw string) (limit int, ok bool) {
|
||
if raw == "" {
|
||
return usageModelsDefaultLimit, true
|
||
}
|
||
n, err := strconv.Atoi(raw)
|
||
if err != nil || n < 1 || n > usageModelsMaxLimit {
|
||
return 0, false
|
||
}
|
||
return n, true
|
||
}
|
||
|
||
// usageModelsPrefixMaxLen bounds GET /admin/api/usage/models' optional
|
||
// "prefix" query parameter (item 6, v0.3 dashboard task round 2): an
|
||
// operator-typed search string from the Charts view's model filter box,
|
||
// not a value with any legitimate reason to run long — a real model id
|
||
// this endpoint ranks over is always far shorter. Bounding it keeps a
|
||
// malformed or hostile query cheap to reject before catalogModelScopeIDs
|
||
// is even walked.
|
||
const usageModelsPrefixMaxLen = 256
|
||
|
||
// parseUsageModelsPrefix parses GET /admin/api/usage/models' optional
|
||
// "prefix" query parameter: empty means no filtering (every catalogued
|
||
// model ranked, this endpoint's original, pre-prefix behavior), otherwise
|
||
// the raw string unchanged, so long as it is at most
|
||
// usageModelsPrefixMaxLen bytes. ok is false for anything longer,
|
||
// mirroring parseUsageModelsLimit/parseUsageModelsSpan's own
|
||
// validate-before-reading shape. No further validation: a prefix that
|
||
// matches nothing in the catalog is not an error, just an empty (still
|
||
// 200) ranking — the same "no non-zero models" shape an ordinary,
|
||
// unfiltered ranking already answers when nothing was used yet.
|
||
func parseUsageModelsPrefix(raw string) (prefix string, ok bool) {
|
||
if len(raw) > usageModelsPrefixMaxLen {
|
||
return "", false
|
||
}
|
||
return raw, true
|
||
}
|
||
|
||
// parseUsageModelsSpan parses GET /admin/api/usage/models' optional
|
||
// "span" query parameter (F2, v0.3 dashboard task): empty means 1 —
|
||
// today's own bucket, this endpoint's original, pre-span behavior —
|
||
// otherwise an integer between 1 and window's own historyMaxSpan
|
||
// inclusive, mirroring parseHistorySpan's own validate-before-reading
|
||
// shape and identical range. window is assumed already validated by
|
||
// validHistoryWindow, exactly like parseHistorySpan's own precondition.
|
||
func parseUsageModelsSpan(raw, window string) (span int, ok bool) {
|
||
if raw == "" {
|
||
return 1, true
|
||
}
|
||
max := historyMaxSpan(window)
|
||
n, err := strconv.Atoi(raw)
|
||
if err != nil || n < 1 || n > max {
|
||
return 0, false
|
||
}
|
||
return n, true
|
||
}
|
||
|
||
// usageModelsDetailOn and usageModelsDetailOff enumerate GET
|
||
// /admin/api/usage/models' optional "detail" query parameter's accepted
|
||
// values (free-models feature): "1"/"true" turn detail on, ""/"0"/
|
||
// "false" leave it off (the byte-identical legacy response shape).
|
||
// Anything else is a 400, mirroring this endpoint's own
|
||
// validate-before-reading shape for every other parameter.
|
||
const (
|
||
usageModelsDetailOn1 = "1"
|
||
usageModelsDetailOn2 = "true"
|
||
usageModelsDetailOff0 = "0"
|
||
usageModelsDetailOff1 = "false"
|
||
usageModelsDetailOffRaw = ""
|
||
)
|
||
|
||
// parseUsageModelsDetail parses GET /admin/api/usage/models' optional
|
||
// "detail" query parameter: empty, "0" or "false" means off (today's
|
||
// byte-identical response — no Requests/TokensIn/TokensOut/CostMicroUSD/
|
||
// Free fields, no "detail" key at all thanks to its own omitempty); "1"
|
||
// or "true" means on. ok is false for anything else.
|
||
func parseUsageModelsDetail(raw string) (detail bool, ok bool) {
|
||
switch raw {
|
||
case usageModelsDetailOffRaw, usageModelsDetailOff0, usageModelsDetailOff1:
|
||
return false, true
|
||
case usageModelsDetailOn1, usageModelsDetailOn2:
|
||
return true, true
|
||
default:
|
||
return false, false
|
||
}
|
||
}
|
||
|
||
// catalogModelScopeIDs returns every catalogued (provider, model) pair's
|
||
// canonical kindModel scope id, in the registry snapshot's own order. It
|
||
// is the enumeration GET /admin/api/usage/models ranks over, and the same
|
||
// one scopeExists validates a model scope against, so the two can never
|
||
// disagree about which model ids exist.
|
||
func (g *Gateway) catalogModelScopeIDs() []string {
|
||
snaps := g.registry.snapshot()
|
||
ids := make([]string, 0, len(snaps))
|
||
for _, s := range snaps {
|
||
for _, model := range s.models {
|
||
ids = append(ids, providerModelScopeID(s.name, model))
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
// adminUsageModelEntryView is one ranked model in GET
|
||
// /admin/api/usage/models: its canonical "provider/model" id and its
|
||
// total for the requested metric over the requested window's CURRENT
|
||
// bucket. detail=1 (free-models feature) additionally populates
|
||
// Requests/TokensIn/TokensOut/CostMicroUSD — pointers so a genuinely
|
||
// zero total is still emitted rather than indistinguishable from "not
|
||
// requested" — and Free. Every detail-only field is omitted (not
|
||
// zeroed) in the non-detail response, keeping it byte-identical to
|
||
// before this feature existed.
|
||
type adminUsageModelEntryView struct {
|
||
// Requests, TokensIn, TokensOut and CostMicroUSD are populated only
|
||
// by serveAdminUsageModelsDetail (detail=1); the non-detail path
|
||
// never sets them, so they stay nil and omitempty drops them.
|
||
// Ordered ahead of ID/Value/Free for fieldalignment (govet: minimal
|
||
// GC pointer-scan prefix) — JSON names are what's binding, not Go
|
||
// field order or the JSON key order it produces (the plan's own
|
||
// ruling, free-models plan's "Response" section).
|
||
Requests *int64 `json:"requests,omitempty"`
|
||
TokensIn *int64 `json:"tokensIn,omitempty"`
|
||
TokensOut *int64 `json:"tokensOut,omitempty"`
|
||
CostMicroUSD *int64 `json:"costMicroUsd,omitempty"`
|
||
// CacheHits and CacheSavedMicroUsd (admin dashboard redesign, WP-B)
|
||
// are populated only in detail mode, only when features.cacheStats
|
||
// (the response cache is configured), AND only when window is "day"
|
||
// or "month" — nil for a window="hour" request too (P2 fix,
|
||
// serveAdminUsageModelsDetail's own doc comment): a model's chit/csave
|
||
// only ever have a DAY bucket, and there is no exact way to recover an
|
||
// hour-range value from whole-day sums.
|
||
CacheHits *int64 `json:"cacheHits,omitempty"`
|
||
CacheSavedMicroUSD *int64 `json:"cacheSavedMicroUsd,omitempty"`
|
||
// R402 (admin dashboard redesign, WP-B) is populated only in detail
|
||
// mode: how many requests for this model were refused as unpriced
|
||
// under a caller's cost budget (metricR402) over the requested span.
|
||
R402 *int64 `json:"r402,omitempty"`
|
||
ID string `json:"id"`
|
||
// PriceSource (admin dashboard redesign, WP-B) is populated only in
|
||
// detail mode: billingPriceSource's own result (pricing.go) for this
|
||
// model — the same "override"|"builtin"|"litellm"|"free"|"unpriced"
|
||
// vocabulary GET /admin/api/catalog's adminCatalogModel.PriceSource
|
||
// already uses.
|
||
PriceSource string `json:"priceSource,omitempty"`
|
||
Value int64 `json:"value"`
|
||
// Free is populated only by serveAdminUsageModelsDetail too; false
|
||
// (the non-detail zero value) is dropped by its own omitempty.
|
||
Free bool `json:"free,omitempty"`
|
||
}
|
||
|
||
// adminUsageModelsResponse is GET /admin/api/usage/models' body. Span
|
||
// (F2, v0.3 dashboard task) echoes the resolved span (1 when the request
|
||
// carried no ?span= parameter — parseUsageModelsSpan's own default).
|
||
// Detail (free-models feature) echoes whether the request carried
|
||
// detail=1 (parseUsageModelsDetail) — omitted (false) via its own
|
||
// omitempty for the legacy, byte-identical detail=0/absent response.
|
||
type adminUsageModelsResponse struct {
|
||
Metric string `json:"metric"`
|
||
Window string `json:"window"`
|
||
Models []adminUsageModelEntryView `json:"models"`
|
||
Span int `json:"span"`
|
||
// Offset (admin dashboard redesign, WP-B) echoes the resolved offset
|
||
// (0 when the request carried no ?offset= parameter —
|
||
// parseStatsOffset's own default) — how many whole window-steps back
|
||
// from the current bucket this ranking's span starts.
|
||
Offset int `json:"offset"`
|
||
Detail bool `json:"detail,omitempty"`
|
||
}
|
||
|
||
// usageModelsChunkKeys bounds how many counterStore keys
|
||
// chunkedModelSpanTotals (below) reads in a single storeGetMulti round
|
||
// trip — adminUsageChunkScopes' own reasoning (buildAdminUsage, above)
|
||
// applied to the catalog-sized, now span-multiplied read GET
|
||
// /admin/api/usage/models performs once a caller asks for span > 1: with
|
||
// ?span=24 on an hour window and a 400-model catalog, an unchunked read
|
||
// would pipeline 400*24 = 9,600 keys in one call, holding the shared
|
||
// Redis connection mutex (respClient, resp.go) for that whole pipeline
|
||
// while live traffic's own checkAndCount/account calls queue behind it.
|
||
const usageModelsChunkKeys = 1600
|
||
|
||
// chunkedModelSpanTotals calls limiter.modelSpanTotals in batches sized
|
||
// so each batch's own key count (len(chunk)*span) stays at or under
|
||
// usageModelsChunkKeys — mirroring chunkedCurrentUsage's chunking
|
||
// (above), sized for span instead of the fixed usageKeysPerScope stride.
|
||
// now is resolved once by the caller and threaded through every chunk, so
|
||
// every id's span ends at the identical instant regardless of how many
|
||
// chunks the catalog splits into. Any chunk that fails (storeDown or a
|
||
// mismatched read) fails the whole call: a ranking silently missing an
|
||
// arbitrary subset of models is worse than a 503, so this never returns a
|
||
// partial ranking.
|
||
//
|
||
// P12 fix (admin dashboard redesign verify round): now a thin
|
||
// kind=kindModel wrapper over limits.go's spanTotalsMulti — the ONE
|
||
// production span-sum reader this function's own chunking logic used to
|
||
// duplicate (byte-for-byte) against admin.go's own separate
|
||
// chunkedModelSpanTotalsMulti below AND limits_helpers_test.go's
|
||
// test-only modelSpanTotals/modelSpanTotalsMulti. See spanTotalsMulti's
|
||
// own doc comment for the chunking contract this preserves unchanged.
|
||
func (g *Gateway) chunkedModelSpanTotals(ids []string, metric, window string, now time.Time, span, offset int) ([]int64, bool) {
|
||
out, ok := g.limiter.spanTotalsMulti(kindModel, ids, []string{metric}, window, now, span, offset)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
return out[0], true
|
||
}
|
||
|
||
// chunkedModelSpanTotalsMulti is chunkedModelSpanTotals generalized to
|
||
// several metrics read together (GET /admin/api/usage/models' detail=1
|
||
// mode, free-models feature: it needs all four metrics —
|
||
// req/tokin/tokout/cost — for every candidate id, not just the one the
|
||
// ranking sorts by). P12 fix (admin dashboard redesign verify round): now
|
||
// a thin kind=kindModel wrapper over limits.go's spanTotalsMulti — see
|
||
// chunkedModelSpanTotals' own doc comment, immediately above, for what
|
||
// this replaced.
|
||
func (g *Gateway) chunkedModelSpanTotalsMulti(ids, metrics []string, window string, now time.Time, span, offset int) ([][]int64, bool) {
|
||
return g.limiter.spanTotalsMulti(kindModel, ids, metrics, window, now, span, offset)
|
||
}
|
||
|
||
// serveAdminUsageModels writes GET /admin/api/usage/models' ranking of the
|
||
// most-used models for one metric/window (the Charts view's "Models"
|
||
// tab): "metric"=req|tokin|tokout|cost, "window"=hour|day|month, optional
|
||
// "limit"=N (default and max per the constants above), optional
|
||
// "prefix"=STRING (item 6, v0.3 dashboard task round 2: the Charts
|
||
// view's model search box) narrowing the catalog to ids that start with
|
||
// it before anything is ranked — see below, optional "detail"=1|true
|
||
// (free-models feature; parseUsageModelsDetail) switching to
|
||
// serveAdminUsageModelsDetail below. Every parameter is validated before
|
||
// the store is touched — 400 for an unrecognized metric or window, an
|
||
// out-of-range limit, a prefix over usageModelsPrefixMaxLen, or an
|
||
// unrecognized detail value.
|
||
//
|
||
// ONLY NON-ZERO models are returned, by explicit operator requirement: the
|
||
// catalog runs to hundreds of models and a ranking padded with zeroes is
|
||
// unreadable. The filter also keeps the response small even though the
|
||
// READ is catalog-sized — one counter per configured model in a single
|
||
// storeGetMulti (limiter.modelTotals), which is the same
|
||
// one-batched-read-per-poll shape serveAdminOverview already uses for
|
||
// per-provider counters.
|
||
//
|
||
// prefix narrows catalogModelScopeIDs' own output BEFORE
|
||
// chunkedModelSpanTotals ever reads a counter for it, and before limit is
|
||
// applied — not a post-hoc filter over an already-ranked, already-limited
|
||
// result. A caller searching the Charts view down to one provider's
|
||
// models (a common id shape here is "provider/model") must not still pay
|
||
// a catalog-sized store read for ids its own prefix already excludes, and
|
||
// limit must cap the FILTERED set's own top-N, not silently return fewer
|
||
// than limit matches because the unfiltered ranking's top N happened to
|
||
// fall outside the prefix.
|
||
//
|
||
// Ties break on the id, ascending, so a ranking of equal values is stable
|
||
// across polls rather than reshuffling under the reader. A storeDown read
|
||
// answers 503, never a silently-empty ranking — the same rule
|
||
// serveAdminUsageHistory applies, and for the same reason: "the store was
|
||
// unreachable" must never render as "no model was used".
|
||
//
|
||
// detail=0 (or an absent detail parameter) is byte-identical to this
|
||
// endpoint's behavior before the free-models feature existed — the
|
||
// branch below never runs, and adminUsageModelsResponse's own Detail
|
||
// field and every adminUsageModelEntryView detail-only field stay at
|
||
// their zero value, which their shared omitempty tag drops from the
|
||
// JSON entirely.
|
||
func (g *Gateway) serveAdminUsageModels(sw *statusTrackingWriter, r *http.Request) {
|
||
q := r.URL.Query()
|
||
|
||
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
|
||
}
|
||
limit, ok := parseUsageModelsLimit(q.Get("limit"))
|
||
if !ok {
|
||
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid limit")
|
||
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
|
||
}
|
||
prefix, ok := parseUsageModelsPrefix(q.Get("prefix"))
|
||
if !ok {
|
||
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid prefix")
|
||
return
|
||
}
|
||
detail, ok := parseUsageModelsDetail(q.Get("detail"))
|
||
if !ok {
|
||
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid detail")
|
||
return
|
||
}
|
||
|
||
ids := g.catalogModelScopeIDs()
|
||
if prefix != "" {
|
||
filtered := make([]string, 0, len(ids))
|
||
for _, id := range ids {
|
||
if strings.HasPrefix(id, prefix) {
|
||
filtered = append(filtered, id)
|
||
}
|
||
}
|
||
ids = filtered
|
||
}
|
||
// P7 fix (admin dashboard redesign verify round): this cap used to
|
||
// check len(ids)*span regardless of detail mode, but detail mode
|
||
// reads up to 4 base metrics (req/tokin/tokout/cost) PLUS r402
|
||
// (always, P2 fix) PLUS chit/csave (when features.cacheStats and the
|
||
// window isn't hour) per id — up to 7x the single-metric estimate
|
||
// this check assumed. keyMultiplier over-estimates when a month
|
||
// window's cache/r402 reads convert to a smaller, retention-bounded
|
||
// day span (P2 fix, monthRangeToDayBuckets) — that only ever rejects
|
||
// a request earlier than strictly necessary, never lets an oversized
|
||
// one through.
|
||
keyMultiplier := 1
|
||
if detail {
|
||
keyMultiplier = len(usageModelsDetailMetrics()) + 1 // +1: r402, always read
|
||
if g.buildAdminFeatures().CacheStats {
|
||
keyMultiplier += 2 // chit, csave
|
||
}
|
||
}
|
||
if len(ids)*span*keyMultiplier > adminMaxKeysPerRequest {
|
||
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
|
||
return
|
||
}
|
||
|
||
if detail {
|
||
g.serveAdminUsageModelsDetail(sw, ids, metric, window, span, offset, limit)
|
||
return
|
||
}
|
||
|
||
totals, storeOK := g.chunkedModelSpanTotals(ids, metric, window, g.limiter.now(), span, offset)
|
||
if !storeOK {
|
||
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable")
|
||
return
|
||
}
|
||
|
||
models := make([]adminUsageModelEntryView, 0, len(ids))
|
||
for i, id := range ids {
|
||
if totals[i] == 0 {
|
||
continue
|
||
}
|
||
models = append(models, adminUsageModelEntryView{ID: id, Value: totals[i]})
|
||
}
|
||
sort.Slice(models, func(i, j int) bool {
|
||
if models[i].Value != models[j].Value {
|
||
return models[i].Value > models[j].Value
|
||
}
|
||
return models[i].ID < models[j].ID
|
||
})
|
||
if len(models) > limit {
|
||
models = models[:limit]
|
||
}
|
||
|
||
setAdminJSONHeaders(sw)
|
||
_ = json.NewEncoder(sw).Encode(adminUsageModelsResponse{Metric: metric, Window: window, Span: span, Offset: offset, Models: models})
|
||
}
|
||
|
||
// usageModelsDetailMetrics is the fixed metric read/unpack order
|
||
// serveAdminUsageModelsDetail uses: req, tokin, tokout, cost — matching
|
||
// adminUsageModelEntryView's own field order (Requests, TokensIn,
|
||
// TokensOut, CostMicroUSD). Built fresh per call rather than as a
|
||
// package-level var (go.md: no mutable package-level state) — the cost
|
||
// of one 4-element slice literal per detail request is immaterial next
|
||
// to the storeGetMulti round trip it drives.
|
||
func usageModelsDetailMetrics() []string {
|
||
return []string{metricReq, metricTokIn, metricTokOut, metricCost}
|
||
}
|
||
|
||
// usageModelsMetricIndex maps metric — already validated by
|
||
// validHistoryMetric, so always one of the four cases below — to its
|
||
// position in usageModelsDetailMetrics, picking out the "value" column
|
||
// GET /admin/api/usage/models' detail=1 mode ranks by: whichever metric
|
||
// the caller actually asked for, even though detail mode always reads
|
||
// all four.
|
||
func usageModelsMetricIndex(metric string) int {
|
||
switch metric {
|
||
case metricReq:
|
||
return 0
|
||
case metricTokIn:
|
||
return 1
|
||
case metricTokOut:
|
||
return 2
|
||
default: // metricCost: the only case validHistoryMetric still allows through
|
||
return 3
|
||
}
|
||
}
|
||
|
||
// adminUsageModelFree reports whether canonical (a catalogued
|
||
// "provider/model" id, bare its part after the first "/") is free for
|
||
// GET /admin/api/usage/models' detail=1 mode: either an operator
|
||
// modelMeta entry marks it Free (modelMetaFree, modelmeta.go — the SAME
|
||
// two-key lookup billing itself uses, so this view and actual billing
|
||
// can never disagree), or its resolved price (resolveUnifiedPricing,
|
||
// failover.go — the identical resolution unifiedCostMicros itself uses)
|
||
// is KNOWN and both per-token prices are exactly 0. The second check
|
||
// matters on its own: a configured or built-in price table entry that
|
||
// happens to bill nothing is just as "free" to an operator reading this
|
||
// ranking as an explicit modelMeta:{free:true}, but a genuinely UNKNOWN
|
||
// price must never render as free — resolveUnifiedPricing's own ok
|
||
// return keeps the two cases apart.
|
||
func adminUsageModelFree(canonical, bare string, overrides map[string]*ModelPricing, meta map[string]*ModelMetaConfig) bool {
|
||
if modelMetaFree(canonical, bare, meta) {
|
||
return true
|
||
}
|
||
price, known := resolveUnifiedPricing(canonical, bare, overrides)
|
||
return known && price.InputPerM == 0 && price.OutputPerM == 0
|
||
}
|
||
|
||
// serveAdminUsageModelsDetail is serveAdminUsageModels' detail=1 branch
|
||
// (free-models feature): ids (already prefix-filtered), metric, window,
|
||
// span and limit are all already validated by the caller. Unlike the
|
||
// non-detail path, an id is included when ANY of its four metrics is
|
||
// nonzero, not just the requested one — so a free model with real
|
||
// request traffic but zero cost still appears in a metric=cost ranking,
|
||
// at Value=0, instead of being dropped the way the non-detail path would
|
||
// drop it. Ties break on value desc, then requests desc, then id asc:
|
||
// once two free models tie on the requested metric (typically cost, both
|
||
// zero), requests is the natural next signal, ahead of the id fallback
|
||
// every ranking already uses to stay stable across polls.
|
||
// monthRangeToDayBuckets converts a windowMonth span/offset (relative to
|
||
// now) into the windowDay span/offset covering the EXACT SAME calendar
|
||
// range — P2 fix, admin dashboard redesign verify round: GET
|
||
// /admin/api/usage/models' detail=1 mode needs this for the counter
|
||
// families that have no month bucket of their own (r402) or only ever
|
||
// have a DAY bucket at the model scope (chit/csave — plan §1.2's "model
|
||
// day only" row). Month and day buckets both align on calendar
|
||
// boundaries (historyStepBack's own month case always anchors on the 1st
|
||
// of a month, limits.go), so this conversion is exact, not an
|
||
// approximation: every day it names falls entirely inside the requested
|
||
// month range, and every day in that range is named. A day outside the
|
||
// day family's own retention (historyMaxSpan(windowDay), 35) simply reads
|
||
// back 0 through the store, which is the correct answer for data that
|
||
// was never kept that long — the returned span is capped at that
|
||
// retention purely to avoid building a key list for months of days none
|
||
// of which could possibly hold live data.
|
||
func monthRangeToDayBuckets(now time.Time, span, offset int) (daySpan, dayOffset int) {
|
||
u := now.UTC()
|
||
today := time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
|
||
monthAnchor := time.Date(u.Year(), u.Month(), 1, 0, 0, 0, 0, time.UTC)
|
||
lastMonth := monthAnchor.AddDate(0, -offset, 0)
|
||
firstMonth := lastMonth.AddDate(0, -(span - 1), 0)
|
||
lastDay := today
|
||
if offset > 0 {
|
||
lastDay = lastMonth.AddDate(0, 1, -1) // last calendar day of lastMonth
|
||
}
|
||
dayOffset = int(today.Sub(lastDay).Hours() / 24)
|
||
daySpan = int(lastDay.Sub(firstMonth).Hours()/24) + 1
|
||
if max := historyMaxSpan(windowDay) - dayOffset; daySpan > max {
|
||
if max < 1 {
|
||
max = 1
|
||
}
|
||
daySpan = max
|
||
}
|
||
return daySpan, dayOffset
|
||
}
|
||
|
||
func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []string, metric, window string, span, offset, limit int) {
|
||
now := g.limiter.now()
|
||
metrics := usageModelsDetailMetrics()
|
||
totalsByMetric, storeOK := g.chunkedModelSpanTotalsMulti(ids, metrics, window, now, span, offset)
|
||
if !storeOK {
|
||
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable")
|
||
return
|
||
}
|
||
valueIdx := usageModelsMetricIndex(metric)
|
||
|
||
// r402 (admin dashboard redesign, WP-B; P2 fix, verify round): ALWAYS
|
||
// read, independent of features.cacheStats — r402 counts refused-
|
||
// unpriced-under-a-cost-budget requests, unrelated to the response
|
||
// cache. It used to be folded into the SAME cache-gated read as
|
||
// chit/csave, forced to windowDay regardless of the caller's own
|
||
// window — wrong on two counts: gated on cache config at all, and
|
||
// accountWith's own recordUnpriced402 (limits.go) writes a model's
|
||
// r402 at BOTH hour and day, so an hour-window request silently read
|
||
// 24-120x too many day buckets while a month-window request read only
|
||
// 1 day per month instead of the whole month. Read at the caller's
|
||
// own window when r402 has a bucket there (hour or day, exact);
|
||
// convert to the covering day buckets when it is month (r402 has no
|
||
// month bucket — monthRangeToDayBuckets, above, is exact for this).
|
||
r402Window, r402Span, r402Offset := window, span, offset
|
||
if window == windowMonth {
|
||
r402Window = windowDay
|
||
r402Span, r402Offset = monthRangeToDayBuckets(now, span, offset)
|
||
}
|
||
r402Multi, storeOK := g.chunkedModelSpanTotalsMulti(ids, []string{metricR402}, r402Window, now, r402Span, r402Offset)
|
||
if !storeOK {
|
||
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable")
|
||
return
|
||
}
|
||
r402ByID := r402Multi[0]
|
||
|
||
// chit/csave (admin dashboard redesign, WP-B): only when
|
||
// features.cacheStats (the response cache is configured) — nil
|
||
// otherwise, as before. Unlike r402, a model's chit/csave are ONLY
|
||
// ever written at windowDay (recordCacheHit, limits.go — plan §1.2's
|
||
// "model day only" row), so there is no hour bucket to read at all:
|
||
// window==day reads directly; window==month converts to the exact
|
||
// covering day buckets, same as r402 above; window==hour is left
|
||
// OMITTED (both fields stay nil, not a fabricated or over-counted
|
||
// number) — an hour range's true chit/csave cannot be recovered from
|
||
// whole-day sums without also counting every hour of those days OUTSIDE
|
||
// the requested range, so there is no exact answer to give (documented
|
||
// in README.md's GET /admin/api/usage/models section).
|
||
cacheEnabled := g.buildAdminFeatures().CacheStats
|
||
var chitByID, csaveByID []int64
|
||
if cacheEnabled && window != windowHour {
|
||
cacheWindow, cacheSpan, cacheOffset := window, span, offset
|
||
if window == windowMonth {
|
||
cacheWindow = windowDay
|
||
cacheSpan, cacheOffset = monthRangeToDayBuckets(now, span, offset)
|
||
}
|
||
var cacheMulti [][]int64
|
||
cacheMulti, storeOK = g.chunkedModelSpanTotalsMulti(ids, []string{metricChit, metricCsave}, cacheWindow, now, cacheSpan, cacheOffset)
|
||
if !storeOK {
|
||
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable")
|
||
return
|
||
}
|
||
chitByID, csaveByID = cacheMulti[0], cacheMulti[1]
|
||
}
|
||
|
||
models := make([]adminUsageModelEntryView, 0, len(ids))
|
||
for i, id := range ids {
|
||
req := totalsByMetric[0][i]
|
||
tokIn := totalsByMetric[1][i]
|
||
tokOut := totalsByMetric[2][i]
|
||
cost := totalsByMetric[3][i]
|
||
if req == 0 && tokIn == 0 && tokOut == 0 && cost == 0 {
|
||
continue
|
||
}
|
||
_, bare, _ := strings.Cut(id, "/")
|
||
source, _ := billingPriceSource(id, bare, g.cfg.Pricing, g.cfg.ModelMeta)
|
||
r402 := r402ByID[i]
|
||
entry := adminUsageModelEntryView{
|
||
ID: id,
|
||
Value: totalsByMetric[valueIdx][i],
|
||
Requests: &req,
|
||
TokensIn: &tokIn,
|
||
TokensOut: &tokOut,
|
||
CostMicroUSD: &cost,
|
||
Free: adminUsageModelFree(id, bare, g.cfg.Pricing, g.cfg.ModelMeta),
|
||
PriceSource: source,
|
||
R402: &r402,
|
||
}
|
||
if chitByID != nil {
|
||
chit := chitByID[i]
|
||
csave := csaveByID[i]
|
||
entry.CacheHits = &chit
|
||
entry.CacheSavedMicroUSD = &csave
|
||
}
|
||
models = append(models, entry)
|
||
}
|
||
sort.Slice(models, func(i, j int) bool {
|
||
if models[i].Value != models[j].Value {
|
||
return models[i].Value > models[j].Value
|
||
}
|
||
if *models[i].Requests != *models[j].Requests {
|
||
return *models[i].Requests > *models[j].Requests
|
||
}
|
||
return models[i].ID < models[j].ID
|
||
})
|
||
if len(models) > limit {
|
||
models = models[:limit]
|
||
}
|
||
|
||
setAdminJSONHeaders(sw)
|
||
_ = json.NewEncoder(sw).Encode(adminUsageModelsResponse{Metric: metric, Window: window, Span: span, Offset: offset, Models: models, Detail: true})
|
||
}
|
||
|
||
// parseEventsLimit parses GET /admin/api/events' "limit" query parameter
|
||
// (F3, v0.3 dashboard task): empty means adminEventsDefaultLimit,
|
||
// otherwise an integer between 1 and adminEventsMaxLimit inclusive,
|
||
// mirroring parseUsageModelsLimit's own validate-before-reading shape.
|
||
func parseEventsLimit(raw string) (limit int, ok bool) {
|
||
if raw == "" {
|
||
return adminEventsDefaultLimit, true
|
||
}
|
||
n, err := strconv.Atoi(raw)
|
||
if err != nil || n < 1 || n > adminEventsMaxLimit {
|
||
return 0, false
|
||
}
|
||
return n, true
|
||
}
|
||
|
||
// serveAdminEvents writes GET /admin/api/events' most recent gateway
|
||
// events (F3, v0.3 dashboard task): rate-limit/budget/store-down
|
||
// admission rejections and upstream/timeout/unpriced/capacity error-path
|
||
// signals, newest first. "limit" is optional (adminEventsDefaultLimit),
|
||
// 1..adminEventsMaxLimit inclusive otherwise 400. Never calls
|
||
// checkAndCount — the same admin-traffic-must-not-move-usage-statistics
|
||
// rule every other /admin/api/* route already follows (handleAdminAPI's
|
||
// own doc comment). Answers from the shared Redis-backed list when
|
||
// reachable (source "redis"), falling back to this replica's own local
|
||
// ring otherwise (source "replica", Degraded true when Redis is
|
||
// configured but this particular read failed) — fail-open: the local
|
||
// ring always answers (eventLog.read's own doc comment, events.go).
|
||
func (g *Gateway) serveAdminEvents(sw *statusTrackingWriter, r *http.Request) {
|
||
limit, ok := parseEventsLimit(r.URL.Query().Get("limit"))
|
||
if !ok {
|
||
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid limit")
|
||
return
|
||
}
|
||
events, source, degraded := g.events.read(limit, g.limiter.storeLatched())
|
||
if events == nil {
|
||
events = []gatewayEvent{}
|
||
}
|
||
setAdminJSONHeaders(sw)
|
||
_ = json.NewEncoder(sw).Encode(adminEventsResponse{
|
||
Events: events, Source: source, Replica: g.replica, Capacity: eventRingCap, Degraded: degraded,
|
||
})
|
||
}
|
||
|
||
// adminTargetCountersView is one target's current-window request counters
|
||
// in GET /admin/api/targets (Feature B, v0.21) — requests only, mirroring
|
||
// limiter.targetCounters (limits.go) field for field; a target never
|
||
// accumulates tokens or cost (handleTargetProxy's own doc comment,
|
||
// mcp_a2a.go), so there is nothing else to report here.
|
||
type adminTargetCountersView struct {
|
||
RequestsPerMinute int64 `json:"requestsPerMinute"`
|
||
RequestsPerDay int64 `json:"requestsPerDay"`
|
||
RequestsPerMonth int64 `json:"requestsPerMonth"`
|
||
}
|
||
|
||
// adminTargetView is one configured MCP server's or A2A agent's row in GET
|
||
// /admin/api/targets. Access lists the group names actually allowed to
|
||
// reach this target — computed by running the SAME matchesGlob call
|
||
// group.allowsMCP/allowsAgent themselves delegate to (auth.go) against
|
||
// each group's own cloned mcpServers/agents pattern list (groupSummary,
|
||
// authStore.snapshot), so this view can never disagree with what
|
||
// handleTargetProxy actually enforces.
|
||
//
|
||
// Access's JSON contract (review-auth finding F2, 2026-09 review — fixed
|
||
// from a prior "omitempty" tag): "access":null means every configured
|
||
// group can reach this target ("empty meaning all" — adminTargetAccess
|
||
// returns a nil slice for that case); "access":[] (present, zero-length)
|
||
// means NO configured group can reach it; "access":["g1",...] lists the
|
||
// groups that can. The prior omitempty tag made the first two cases
|
||
// serialize identically (both simply absent from the JSON body), so a
|
||
// target restricted to zero groups rendered in the webui exactly like an
|
||
// unrestricted one — the opposite of what its own access rule enforces.
|
||
// No omitempty here specifically because that distinction depends on nil
|
||
// vs. non-nil-but-empty, which omitempty collapses. adminTargetAccess's
|
||
// own return value is unchanged by this fix (it already returned nil for
|
||
// "all" and a non-nil, possibly zero-length, slice otherwise) — only this
|
||
// struct's serialization of that value changes. The webui (webui/src/
|
||
// types/api.ts's AdminTargetView.access, webui/src/lib/target-columns.ts)
|
||
// must be updated to treat null/undefined and a zero-length array as
|
||
// DIFFERENT states, distinct from today's "!access?.length means All
|
||
// groups" check that treats both alike; report this contract to whoever
|
||
// owns that file.
|
||
//
|
||
// Health is feat/target-health's own readout (targetHealthView, below).
|
||
// Field order (Health first, the struct-typed field, then the strings/
|
||
// slice, then Counters last) is fieldalignment-sensitive, derived
|
||
// against a scratch copy the same way this package's other structs
|
||
// already document (Config's own doc comment, llmgateway.go).
|
||
type adminTargetView struct {
|
||
Health adminTargetHealthView `json:"health"`
|
||
Name string `json:"name"`
|
||
URL string `json:"url"`
|
||
Access []string `json:"access"`
|
||
Counters adminTargetCountersView `json:"counters"`
|
||
}
|
||
|
||
// adminTargetHealthView is one target's feat/target-health readout in
|
||
// GET /admin/api/targets — the exact JSON contract the webui's MCP &
|
||
// Agents panel codes against. State and ConsecutiveFailures are always
|
||
// present. State "unknown" (targetHealthUnknown) covers two different
|
||
// situations (F4, feat/target-health review) — see that constant's own
|
||
// doc comment — and this view tells them apart by whether the tracker
|
||
// has ever observed the target at all, NOT by State alone:
|
||
// - never observed: every other field is omitted, since none of them
|
||
// carry a meaningful value yet.
|
||
// - observed, but never once succeeded (still below
|
||
// targetHealth.failureThreshold): every field below is still
|
||
// present, exactly as for "healthy"/"unhealthy" — there IS a real
|
||
// lastCheck/lastError/source/latencyMs to show, the tracker simply
|
||
// has not seen this target succeed yet.
|
||
//
|
||
// LatencyMs is a pointer so a genuine 0ms observation still serializes
|
||
// as "latencyMs":0 rather than being indistinguishable from "omitted".
|
||
// Field order is fieldalignment-derived, the same convention
|
||
// adminTargetView's own doc comment above explains.
|
||
type adminTargetHealthView struct {
|
||
LatencyMs *int64 `json:"latencyMs,omitempty"`
|
||
State targetHealthState `json:"state"`
|
||
LastCheck string `json:"lastCheck,omitempty"`
|
||
LastError string `json:"lastError,omitempty"`
|
||
Source string `json:"source,omitempty"`
|
||
ConsecutiveFailures int `json:"consecutiveFailures"`
|
||
}
|
||
|
||
// targetHealthView converts one targetHealthTracker.snapshot result
|
||
// (target_health.go) into its JSON view. Whether to omit the non-always-
|
||
// present fields is decided by snap.observed, NOT snap.state ==
|
||
// targetHealthUnknown (adminTargetHealthView's own doc comment explains
|
||
// why those are different questions since F4).
|
||
func targetHealthView(snap targetHealthSnapshot) adminTargetHealthView {
|
||
view := adminTargetHealthView{State: snap.state, ConsecutiveFailures: snap.consecutiveFailures}
|
||
if !snap.observed {
|
||
return view
|
||
}
|
||
view.LastCheck = snap.lastCheck.UTC().Format(time.RFC3339)
|
||
view.LastError = snap.lastError
|
||
view.Source = snap.source
|
||
ms := snap.latency.Milliseconds()
|
||
view.LatencyMs = &ms
|
||
return view
|
||
}
|
||
|
||
// adminTargetsResponse is the full body of GET /admin/api/targets (Feature
|
||
// B, v0.21): every configured MCP server and every configured agent, each
|
||
// with its own access list and current-window request counters. Both
|
||
// slices are sorted by name and built fresh per request, matching every
|
||
// other admin response's "no caching of the response itself" convention.
|
||
type adminTargetsResponse struct {
|
||
MCPServers []adminTargetView `json:"mcpServers"`
|
||
Agents []adminTargetView `json:"agents"`
|
||
}
|
||
|
||
// adminTargetAccess computes one target's access list (adminTargetView.Access's
|
||
// own doc comment): the sorted names of every group in groupSummaries whose
|
||
// own glob pattern list — read via patternsOf, so one function serves both
|
||
// MCPServers and Agents without duplicating this loop — matches name
|
||
// (matchesGlob, auth.go: the exact function group.allowsMCP/allowsAgent
|
||
// themselves call). groupSummaries is already sorted by name
|
||
// (authStore.snapshot's own contract), so the result stays sorted too. A
|
||
// match set covering every configured group collapses to nil ("empty
|
||
// meaning all") rather than echoing the full group list back.
|
||
func adminTargetAccess(groupSummaries []groupSummary, patternsOf func(groupSummary) []string, name string) []string {
|
||
matched := make([]string, 0, len(groupSummaries))
|
||
for _, gs := range groupSummaries {
|
||
if matchesGlob(patternsOf(gs), name) {
|
||
matched = append(matched, gs.name)
|
||
}
|
||
}
|
||
if len(matched) == len(groupSummaries) {
|
||
return nil
|
||
}
|
||
return matched
|
||
}
|
||
|
||
// sortedMCPServerNames and sortedAgentNames return the sorted key list of
|
||
// Config.MCPServers/Config.Agents — the deterministic response order every
|
||
// admin listing in this file already promises (authStore.snapshot's own
|
||
// sorted contract), and the identical pattern handleMCPServers/
|
||
// handleAgents (mcp_a2a.go) already use for their own group-filtered
|
||
// listings. Two near-identical functions, not one generic helper, over a
|
||
// map[string]*T: this plugin runs interpreted under Yaegi, whose stdlib/
|
||
// type-parameter support does not extend to generic code the plugin
|
||
// itself defines (tools/yaegi-check; see auth.go's cloneStringSlice for
|
||
// the same constraint applied to a generic slices.Clone call instead).
|
||
func sortedMCPServerNames(m map[string]*TargetConfig) []string {
|
||
names := make([]string, 0, len(m))
|
||
for name := range m {
|
||
names = append(names, name)
|
||
}
|
||
sort.Strings(names)
|
||
return names
|
||
}
|
||
|
||
func sortedAgentNames(m map[string]*AgentConfig) []string {
|
||
names := make([]string, 0, len(m))
|
||
for name := range m {
|
||
names = append(names, name)
|
||
}
|
||
sort.Strings(names)
|
||
return names
|
||
}
|
||
|
||
// targetCountersView converts one limiter.targetCounters (limits.go) into
|
||
// its JSON view. Not a plain type conversion (unlike adminAliasView's own
|
||
// aliasSnapshotEntry conversion above): targetCounters' fields are
|
||
// unexported (limiter-internal), so its field names do not match
|
||
// adminTargetCountersView's exported ones and Go's identical-underlying-
|
||
// type conversion rule does not apply here.
|
||
func targetCountersView(c targetCounters) adminTargetCountersView {
|
||
return adminTargetCountersView{
|
||
RequestsPerMinute: c.requestsPerMinute,
|
||
RequestsPerDay: c.requestsPerDay,
|
||
RequestsPerMonth: c.requestsPerMonth,
|
||
}
|
||
}
|
||
|
||
// buildAdminTargets assembles adminTargetsResponse: every configured MCP
|
||
// server and agent, their access lists (adminTargetAccess), and their
|
||
// current-window request counters — ONE limiter.targetUsage round trip
|
||
// for every target combined (mirroring buildAdminUsage's own
|
||
// single-batch discipline), not one per target.
|
||
func (g *Gateway) buildAdminTargets() adminTargetsResponse {
|
||
_, groupSummaries := g.auth.snapshot()
|
||
|
||
mcpNames := sortedMCPServerNames(g.cfg.MCPServers)
|
||
agentNames := sortedAgentNames(g.cfg.Agents)
|
||
|
||
scopes := make([]limitScope, 0, len(mcpNames)+len(agentNames))
|
||
for _, name := range mcpNames {
|
||
scopes = append(scopes, limitScope{kind: targetKindMCP, id: name})
|
||
}
|
||
for _, name := range agentNames {
|
||
scopes = append(scopes, limitScope{kind: scopeKindAgent, id: name})
|
||
}
|
||
counters := g.limiter.targetUsage(scopes)
|
||
|
||
mcpServers := make([]adminTargetView, len(mcpNames))
|
||
for i, name := range mcpNames {
|
||
mcpServers[i] = adminTargetView{
|
||
Health: targetHealthView(g.targetHealth.snapshot(targetKindMCP, name)),
|
||
Name: name,
|
||
URL: sanitizeBaseURL(g.cfg.MCPServers[name].URL),
|
||
Access: adminTargetAccess(groupSummaries, func(gs groupSummary) []string { return gs.mcpServers }, name),
|
||
Counters: targetCountersView(counters[i]),
|
||
}
|
||
}
|
||
agents := make([]adminTargetView, len(agentNames))
|
||
for i, name := range agentNames {
|
||
agents[i] = adminTargetView{
|
||
Health: targetHealthView(g.targetHealth.snapshot(targetKindAgent, name)),
|
||
Name: name,
|
||
URL: sanitizeBaseURL(g.cfg.Agents[name].URL),
|
||
Access: adminTargetAccess(groupSummaries, func(gs groupSummary) []string { return gs.agents }, name),
|
||
Counters: targetCountersView(counters[len(mcpNames)+i]),
|
||
}
|
||
}
|
||
|
||
return adminTargetsResponse{MCPServers: mcpServers, Agents: agents}
|
||
}
|
||
|
||
// serveAdminTargets writes buildAdminTargets's result as JSON.
|
||
func (g *Gateway) serveAdminTargets(w http.ResponseWriter) {
|
||
g.maybeSweepTargetHealth()
|
||
setAdminJSONHeaders(w)
|
||
_ = json.NewEncoder(w).Encode(g.buildAdminTargets())
|
||
}
|