feat(admin): add fleet statistics and admin read endpoints

Counters (always on when admin is enabled):
- last seen per user and group, written asynchronously after
  admission, throttled to one write per scope per minute per replica
- hourly provider and model attempts, failures and timeouts
- failovers per provider, rejections per hour, and unpriced (402)
  refusals
- cache hits, misses and cost saved
- request counts per target and caller

Opt-in counters under admin.stats (both default off):
- userModel: usage per user and model
- latency: latency histograms per provider and model

All counters ride the existing Redis batches. A test measures three
round trips per request with defaults and with every statistic on. A
cache hit takes two. A model request is no longer counted for failed
failover candidates.

New admin endpoints:
- usage/series and usage/totals
- performance, with percentiles from the histograms
- catalog, with the price billing actually uses and its source
- consumers, with key source, grants and model access
- config, with secrets redacted

The overview gains features and a pricing summary. Usage gains
lastSeen. usage/models gains offset and more detail fields. Reads are
chunked at 1600 keys per round trip and capped at 64000 keys per
request.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 16:40:45 +01:00
parent 8ac20d8d5e
commit 65b2f121c4
40 changed files with 7723 additions and 506 deletions
+358 -84
View File
@@ -69,11 +69,17 @@ func adminEnabled(cfg *Config) bool {
// 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.
// 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 {
if path == adminPagePath || path == adminOverviewPath || path == adminUsagePath ||
path == adminUsageHistoryPath || path == adminUsageModelsPath || path == adminTargetsPath ||
path == adminEventsPath {
switch path {
case adminPagePath, adminOverviewPath, adminUsagePath, adminUsageHistoryPath, adminUsageModelsPath,
adminUsageSeriesPath, adminUsageTotalsPath, adminPerformancePath,
adminCatalogPath, adminConsumersPath, adminConfigPath,
adminTargetsPath, adminEventsPath:
return true
}
return strings.HasPrefix(path, adminAssetsPathPrefix)
@@ -148,6 +154,18 @@ func (g *Gateway) handleAdminAPI(sw *statusTrackingWriter, r *http.Request) {
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:
@@ -223,13 +241,22 @@ func setAdminJSONHeaders(w http.ResponseWriter) {
// net/url's own URL.Redacted() does: Redacted() would still leave a bare
// embedded username in place, and a bare username with no separate
// password is itself commonly how a token gets embedded in a URL. A raw
// value that fails to parse as a URL is returned unchanged — it names no
// scheme/host/userinfo net/url can identify, so there is nothing
// structured left to strip.
// 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 raw
return unparseableBaseURLPlaceholder
}
u.User = nil
u.RawQuery = ""
@@ -237,6 +264,11 @@ func sanitizeBaseURL(raw string) string {
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
@@ -626,11 +658,108 @@ type adminOverviewResponse struct {
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.
@@ -732,15 +861,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
groups[i] = adminGroupView{Name: gs.name, Limits: gs.limits, MemberCount: gs.memberCount}
}
aliasSnaps := g.registry.aliasSnapshot()
aliases := make([]adminAliasView, len(aliasSnaps))
for i, a := range aliasSnaps {
aliases[i] = adminAliasView{
Alias: a.Alias,
Target: a.Target,
ModelMeta: buildAdminModelMetaView(g.registry.resolveMetaForAliasName(a.Alias)),
}
}
aliases := g.buildAdminAliasViews()
warnings, warningsDropped := g.configWarningsSnapshot()
return adminOverviewResponse{
@@ -750,6 +871,8 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
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,
@@ -758,6 +881,24 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
}
}
// 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)
@@ -810,7 +951,13 @@ type adminUsageEntryView struct {
// 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"`
StoreDown bool `json:"storeDown,omitempty"`
// 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
@@ -841,6 +988,7 @@ func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView {
CostPerDayMicroUSD: su.costPerDayMicros,
CostPerMonthMicroUSD: su.costPerMonthMicros,
RejectionsPerDay: su.rejectionsPerDay,
LastSeen: su.lastSeen,
StoreDown: su.storeDown,
}
}
@@ -884,7 +1032,14 @@ func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView {
// 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.
const adminUsageChunkScopes = 200
//
// 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
@@ -1290,8 +1445,27 @@ type adminUsageModelEntryView struct {
TokensIn *int64 `json:"tokensIn,omitempty"`
TokensOut *int64 `json:"tokensOut,omitempty"`
CostMicroUSD *int64 `json:"costMicroUsd,omitempty"`
ID string `json:"id"`
Value int64 `json:"value"`
// 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"`
@@ -1308,7 +1482,12 @@ type adminUsageModelsResponse struct {
Window string `json:"window"`
Models []adminUsageModelEntryView `json:"models"`
Span int `json:"span"`
Detail bool `json:"detail,omitempty"`
// 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
@@ -1332,66 +1511,32 @@ const usageModelsChunkKeys = 1600
// 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.
func (g *Gateway) chunkedModelSpanTotals(ids []string, metric, window string, now time.Time, span int) ([]int64, bool) {
perChunk := usageModelsChunkKeys / span
if perChunk < 1 {
perChunk = 1
//
// 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
}
out := make([]int64, 0, len(ids))
for len(ids) > 0 {
n := perChunk
if n > len(ids) {
n = len(ids)
}
totals, ok := g.limiter.modelSpanTotals(ids[:n], metric, window, now, span)
if !ok {
return nil, false
}
out = append(out, totals...)
ids = ids[n:]
}
return out, true
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). Each chunk's own key count is
// len(chunk)*span*len(metrics), so perChunk divides usageModelsChunkKeys
// by span*len(metrics) instead of span alone — chunkedModelSpanTotals'
// own reasoning, extended for the extra metrics dimension. now is
// resolved once by the caller and threaded through every chunk, exactly
// like chunkedModelSpanTotals, and any chunk that fails fails the whole
// call for the identical reason: a detail ranking silently missing an
// arbitrary subset of models is worse than a 503.
func (g *Gateway) chunkedModelSpanTotalsMulti(ids, metrics []string, window string, now time.Time, span int) ([][]int64, bool) {
if len(metrics) == 0 || span < 1 {
return nil, true
}
perChunk := usageModelsChunkKeys / (span * len(metrics))
if perChunk < 1 {
perChunk = 1
}
out := make([][]int64, len(metrics))
for m := range out {
out[m] = make([]int64, 0, len(ids))
}
for len(ids) > 0 {
n := perChunk
if n > len(ids) {
n = len(ids)
}
totals, ok := g.limiter.modelSpanTotalsMulti(ids[:n], metrics, window, now, span)
if !ok {
return nil, false
}
for m := range out {
out[m] = append(out[m], totals[m]...)
}
ids = ids[n:]
}
return out, true
// 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
@@ -1460,6 +1605,11 @@ func (g *Gateway) serveAdminUsageModels(sw *statusTrackingWriter, r *http.Reques
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")
@@ -1481,13 +1631,34 @@ func (g *Gateway) serveAdminUsageModels(sw *statusTrackingWriter, r *http.Reques
}
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 {
g.serveAdminUsageModelsDetail(sw, ids, metric, window, span, limit)
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
}
totals, storeOK := g.chunkedModelSpanTotals(ids, metric, window, g.limiter.now(), span)
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
@@ -1511,7 +1682,7 @@ func (g *Gateway) serveAdminUsageModels(sw *statusTrackingWriter, r *http.Reques
}
setAdminJSONHeaders(sw)
_ = json.NewEncoder(sw).Encode(adminUsageModelsResponse{Metric: metric, Window: window, Span: span, Models: models})
_ = json.NewEncoder(sw).Encode(adminUsageModelsResponse{Metric: metric, Window: window, Span: span, Offset: offset, Models: models})
}
// usageModelsDetailMetrics is the fixed metric read/unpack order
@@ -1576,15 +1747,107 @@ func adminUsageModelFree(canonical, bare string, overrides map[string]*ModelPric
// 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.
func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []string, metric, window string, span, limit int) {
// 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, g.limiter.now(), span)
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]
@@ -1595,7 +1858,9 @@ func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []st
continue
}
_, bare, _ := strings.Cut(id, "/")
models = append(models, adminUsageModelEntryView{
source, _ := billingPriceSource(id, bare, g.cfg.Pricing, g.cfg.ModelMeta)
r402 := r402ByID[i]
entry := adminUsageModelEntryView{
ID: id,
Value: totalsByMetric[valueIdx][i],
Requests: &req,
@@ -1603,7 +1868,16 @@ func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []st
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 {
@@ -1619,7 +1893,7 @@ func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []st
}
setAdminJSONHeaders(sw)
_ = json.NewEncoder(sw).Encode(adminUsageModelsResponse{Metric: metric, Window: window, Span: span, Models: models, Detail: true})
_ = 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