diff --git a/admin.go b/admin.go index 7c195f4..22f408d 100644 --- a/admin.go +++ b/admin.go @@ -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 diff --git a/admin_catalog.go b/admin_catalog.go new file mode 100644 index 0000000..2c97117 --- /dev/null +++ b/admin_catalog.go @@ -0,0 +1,304 @@ +package traefikllmgateway + +import ( + "encoding/json" + "net/http" + "sort" + "strings" + "time" +) + +// adminCatalogPath and adminConsumersPath are GET /admin/api/catalog and +// GET /admin/api/consumers' routes (admin dashboard redesign, WP-B) — +// defined here, alongside the rest of these two features' own constants, +// mirroring adminEventsPath's own precedent (events.go). +const ( + adminCatalogPath = "/admin/api/catalog" + adminConsumersPath = "/admin/api/consumers" +) + +// adminCatalogModel is one upstream model's read-only catalog row in GET +// /admin/api/catalog: ContextTokens is resolved display metadata +// (buildAdminModelMetaView, admin.go — the SAME resolution GET +// /admin/api/overview's own adminProviderView.ModelMeta already exposes, +// so the two views can never disagree about a model's context window), +// while InputPerMTokUSD/OutputPerMTokUSD/PriceSource are ALL the SAME +// BILLING resolution (billingPriceSource, pricing.go: modelMetaFree > +// override > builtinPricing > LiteLLM table > unpriced) — the exact +// price/source pair a real request against this model actually gets +// charged, never a separately-resolved display price. +// +// N1 fix (verify-redesign-final.md): an earlier version filled +// InputPerMTokUSD/OutputPerMTokUSD from buildAdminModelMetaView's own +// DISPLAY-layer resolution (operator override > discovery > built-in +// table) instead of billingPriceSource's, so a model with a `pricing:` +// override and no modelMeta/discovery price at all reported +// PriceSource: "override" alongside undefined prices — CostAvoidedCard's +// pickReferenceModel (lib/cost-avoided.ts) would select exactly that +// model as its default reference (its source is neither free nor +// unpriced) and then be unable to price anything against it, and +// PricingHealthTable showed "—" next to a badge claiming a real price +// source. Both fields are nil for `priceSourceFree`/`priceSourceUnpriced` +// (a genuinely free-or-unknown price has nothing to report), and the +// billingPriceSource-resolved price otherwise — always the SAME price +// billingPriceSource itself would report for a real request, by +// construction, never a second, independently-derived number. +type adminCatalogModel struct { + ContextTokens *int `json:"contextTokens,omitempty"` + InputPerMTokUSD *float64 `json:"inputPerMTokUsd,omitempty"` + OutputPerMTokUSD *float64 `json:"outputPerMTokUsd,omitempty"` + ID string `json:"id"` + Model string `json:"model"` + PriceSource string `json:"priceSource"` + Aliases []string `json:"aliases,omitempty"` + DisplayFree bool `json:"displayFree,omitempty"` +} + +// adminCatalogProvider is one configured provider's catalog row in GET +// /admin/api/catalog — the SAME provider identity/health fields GET +// /admin/api/overview's adminProviderView already exposes (LastRefresh, +// OpenUntil, HealthState, DiscoveryEnabled), reused here rather than a +// second, divergent provider summary, plus its full model catalog +// (Models, above). +type adminCatalogProvider struct { + LastRefresh time.Time `json:"lastRefresh"` + OpenUntil time.Time `json:"openUntil"` + Name string `json:"name"` + Type string `json:"type"` + HealthState string `json:"healthState"` + Models []adminCatalogModel `json:"models"` + DiscoveryEnabled bool `json:"discoveryEnabled"` +} + +// adminCatalogResponse is the full body of GET /admin/api/catalog: every +// configured provider's model catalog, plus the SAME alias listing GET +// /admin/api/overview's own Aliases already carries +// (buildAdminAliasViews, admin.go) — the catalog is the Models/ +// Reliability pages' own on-demand data source (plan §3.3: "catalog +// (/catalog on demand, 5-minute stale)"), distinct from the 5s-polled +// overview, which stays compact by design. +type adminCatalogResponse struct { + Providers []adminCatalogProvider `json:"providers"` + Aliases []adminAliasView `json:"aliases"` +} + +// buildAdminCatalog assembles adminCatalogResponse from the SAME +// registry snapshot GET /admin/api/overview reads (g.registry. +// snapshot()) — no live counter read at all, unlike overview: every +// field here is either static configuration or the registry's own +// discovery state, so this endpoint needs no limiter round trip. +func (g *Gateway) buildAdminCatalog() adminCatalogResponse { + snaps := g.registry.snapshot() + + // aliasesByTarget groups every configured alias by its configured + // Target string (ModelAliases' own doc comment, llmgateway.go: a + // target names a canonical "provider/model" id), so each model's own + // Aliases field below is a single map lookup rather than a full scan + // of the alias list per model. + aliasesByTarget := make(map[string][]string) + for _, a := range g.registry.aliasSnapshot() { + aliasesByTarget[a.Target] = append(aliasesByTarget[a.Target], a.Alias) + } + + providers := make([]adminCatalogProvider, len(snaps)) + for i, s := range snaps { + models := make([]adminCatalogModel, len(s.models)) + for j, model := range s.models { + canonical := providerModelScopeID(s.name, model) + metaView := buildAdminModelMetaView(g.registry.resolveMetaFor(s.name, model)) + source, price := billingPriceSource(canonical, model, g.cfg.Pricing, g.cfg.ModelMeta) + displayFree := source == priceSourceUnpriced && strings.HasSuffix(model, freeTierSuffix) + + // N1 fix: InputPerMTokUSD/OutputPerMTokUSD report the SAME + // price billingPriceSource just resolved, never + // metaView's own display-layer price — see + // adminCatalogModel's own doc comment. Left nil for + // priceSourceFree/priceSourceUnpriced: there is no real + // per-token rate to report for either (billingPriceSource + // itself returns the zero ModelPricing for both), and a + // nil price here is exactly what every UI consumer + // (priceCell in both ModelCatalogTable and + // PricingHealthTable) already renders as "—" instead of a + // misleading "$0.00". + var inputPerM, outputPerM *float64 + if source != priceSourceFree && source != priceSourceUnpriced { + in := price.InputPerM + out := price.OutputPerM + inputPerM = &in + outputPerM = &out + } + + var aliases []string + if names := aliasesByTarget[canonical]; len(names) > 0 { + aliases = append(aliases, names...) + sort.Strings(aliases) + } + + models[j] = adminCatalogModel{ + ID: canonical, + Model: model, + ContextTokens: metaView.ContextTokens, + InputPerMTokUSD: inputPerM, + OutputPerMTokUSD: outputPerM, + PriceSource: source, + DisplayFree: displayFree, + Aliases: aliases, + } + } + providers[i] = adminCatalogProvider{ + Name: s.name, + Type: s.typeName, + LastRefresh: s.lastRefresh, + OpenUntil: s.openUntil, + HealthState: s.health.String(), + DiscoveryEnabled: s.discoveryEnabled, + Models: models, + } + } + + return adminCatalogResponse{Providers: providers, Aliases: g.buildAdminAliasViews()} +} + +// serveAdminCatalog writes buildAdminCatalog's result as JSON. +func (g *Gateway) serveAdminCatalog(w http.ResponseWriter) { + setAdminJSONHeaders(w) + _ = json.NewEncoder(w).Encode(g.buildAdminCatalog()) +} + +// adminConsumerUser is one active API-key holder's read-only row in GET +// /admin/api/consumers — Source/Admin/Providers/Models sourced from +// userSummary's own new fields (auth.go's doc comment has the full +// source/personal-grant contract). +type adminConsumerUser struct { + Limits *LimitsConfig `json:"limits,omitempty"` + Name string `json:"name"` + Source string `json:"source"` // inline|file + Groups []string `json:"groups"` + Providers []string `json:"providers,omitempty"` + Models []string `json:"models,omitempty"` + Admin bool `json:"admin,omitempty"` +} + +// adminModelAccessCount is one group's per-provider model-access tally in +// GET /admin/api/consumers: Allowed (how many of that provider's +// catalogued models group.allowsProviderModel authorizes) out of Total +// (that provider's full catalogued model count) — so the Access Matrix +// page can render "12/40 models" beside a provider a group is only +// partially restricted to, without the response carrying the full +// per-model boolean grid. +type adminModelAccessCount struct { + Allowed int `json:"allowed"` + Total int `json:"total"` +} + +// adminConsumerGroup is one configured group's read-only row in GET +// /admin/api/consumers: its configured access lists (groupSummary's own +// doc comment, auth.go) plus two RESOLVED views neither GET +// /admin/api/overview nor GET /admin/api/usage already expose — +// AllowedProviders (every currently catalogued provider name this group +// can reach at all, never nil) and ModelAccess (per-provider allowed/ +// total model tallies, adminModelAccessCount above) — both computed +// against the LIVE registry catalog, not merely echoing the group's own +// configured glob list the way Providers/Models below do. +type adminConsumerGroup struct { + Limits *LimitsConfig `json:"limits,omitempty"` + ModelAccess map[string]adminModelAccessCount `json:"modelAccess"` + Name string `json:"name"` + Providers []string `json:"providers,omitempty"` + Models []string `json:"models,omitempty"` + MCPServers []string `json:"mcpServers,omitempty"` + Agents []string `json:"agents,omitempty"` + PassthroughPaths []string `json:"passthroughPaths,omitempty"` + // AllowedProviders is never nil — an empty (but non-nil) slice for a + // group the live catalog currently has no configured provider for, + // distinct from a JSON "null" a stale client build might otherwise + // mistake for "not yet loaded" (adminTargetView.Access's own doc + // comment, admin.go, documents the identical null-vs-empty + // distinction for a different field). + AllowedProviders []string `json:"allowedProviders"` + MemberCount int `json:"memberCount"` +} + +// adminConsumersResponse is the full body of GET /admin/api/consumers: +// every currently active user and every configured group, redaction-safe +// (auth.go's userSummary/groupSummary doc comments), plus the configured +// users-file PATH ONLY — never its contents (plan §6's own "usersFile +// path shown, contents never" risk note). +type adminConsumersResponse struct { + UsersFile string `json:"usersFile,omitempty"` + Users []adminConsumerUser `json:"users"` + Groups []adminConsumerGroup `json:"groups"` +} + +// buildAdminConsumers assembles adminConsumersResponse: authStore. +// snapshot's own listing (userSummary/groupSummary, auth.go) for the +// redaction-safe fields, plus the live registry catalog (g.registry. +// snapshot()) for AllowedProviders/ModelAccess — computed by calling the +// group's own allowsProvider/allowsProviderModel methods DIRECTLY +// against authStore's internal groups map (g.auth.groups, same package): +// those methods are the exact request-path authorization check +// (allowsProviderModel's own doc comment, auth.go), so this view can +// never authorize (or restrict) a provider/model pair differently than a +// real request would. +func (g *Gateway) buildAdminConsumers() adminConsumersResponse { + userSummaries, groupSummaries := g.auth.snapshot() + snaps := g.registry.snapshot() + + users := make([]adminConsumerUser, len(userSummaries)) + for i, us := range userSummaries { + users[i] = adminConsumerUser{ + Limits: us.limits, + Name: us.name, + Source: us.source, + Groups: us.groups, + Providers: us.personalProviders, + Models: us.personalModels, + Admin: us.admin, + } + } + + groups := make([]adminConsumerGroup, len(groupSummaries)) + for i, gs := range groupSummaries { + grp := g.auth.groups[gs.name] + allowedProviders := make([]string, 0, len(snaps)) + modelAccess := make(map[string]adminModelAccessCount, len(snaps)) + for _, s := range snaps { + allowed := 0 + for _, model := range s.models { + if grp != nil && grp.allowsProviderModel(s.name, model) { + allowed++ + } + } + modelAccess[s.name] = adminModelAccessCount{Allowed: allowed, Total: len(s.models)} + if grp != nil && grp.allowsProvider(s.name) { + allowedProviders = append(allowedProviders, s.name) + } + } + sort.Strings(allowedProviders) + groups[i] = adminConsumerGroup{ + Limits: gs.limits, + Name: gs.name, + Providers: gs.providers, + Models: gs.models, + MCPServers: gs.mcpServers, + Agents: gs.agents, + PassthroughPaths: gs.passthroughPaths, + AllowedProviders: allowedProviders, + ModelAccess: modelAccess, + MemberCount: gs.memberCount, + } + } + + var usersFile string + if g.cfg.Users != nil { + usersFile = g.cfg.Users.File + } + + return adminConsumersResponse{Users: users, Groups: groups, UsersFile: usersFile} +} + +// serveAdminConsumers writes buildAdminConsumers's result as JSON. +func (g *Gateway) serveAdminConsumers(w http.ResponseWriter) { + setAdminJSONHeaders(w) + _ = json.NewEncoder(w).Encode(g.buildAdminConsumers()) +} diff --git a/admin_catalog_test.go b/admin_catalog_test.go new file mode 100644 index 0000000..aa25aa3 --- /dev/null +++ b/admin_catalog_test.go @@ -0,0 +1,337 @@ +package traefikllmgateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" +) + +// --- GET /admin/api/catalog --- + +func TestAdminCatalog_GateMatrix(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + cases := []struct { + name string + apiKey string + want int + }{ + {"unauthenticated", "", http.StatusUnauthorized}, + {"non-admin", "sk-alice", http.StatusForbidden}, + {"admin", "sk-admin1", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminCatalogPath, c.apiKey)) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +// TestAdminCatalog_PriceSourceAndDisplayFree exercises the five billing +// price sources plus the ':free'-suffix display-only case (DECISIONS Q3) +// end to end through GET /admin/api/catalog — including N1's own +// regression (verify-redesign-final.md): "alpha/a-model-1" carries a +// `pricing:` override and NO modelMeta/discovery entry at all, so an +// earlier version (InputPerMTokUSD/OutputPerMTokUSD filled from +// buildAdminModelMetaView's own display-layer resolution instead of +// billingPriceSource's) reported PriceSource: "override" alongside nil +// prices — this table now pins both price fields to the ACTUAL billed +// rate for every priced source, and nil for free/unpriced. +func TestAdminCatalog_PriceSourceAndDisplayFree(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.Pricing = map[string]*ModelPricing{ + "alpha/a-model-1": {InputPerM: 5, OutputPerM: 5}, // override on canonical, no modelMeta/discovery price at all + } + cfg.ModelMeta = map[string]*ModelMetaConfig{ + "alpha/a-model-2": {Free: true}, // modelMeta free + } + cfg.Providers["zeta"].Models = []string{"z-model", "gpt-5", "some-model:free"} + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminCatalogPath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminCatalogResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + + byID := map[string]adminCatalogModel{} + for _, p := range got.Providers { + for _, m := range p.Models { + byID[m.ID] = m + } + } + + builtinGPT5 := builtinPricing["gpt-5"] + cases := []struct { + wantInputPerM *float64 // nil means "must be omitted/nil" + wantOutputPerM *float64 + id string + wantSource string + wantDisplayFree bool + }{ + {ptrFloat64(5), ptrFloat64(5), "alpha/a-model-1", priceSourceOverride, false}, + {nil, nil, "alpha/a-model-2", priceSourceFree, false}, + {ptrFloat64(builtinGPT5.InputPerM), ptrFloat64(builtinGPT5.OutputPerM), "zeta/gpt-5", priceSourceBuiltin, false}, + {nil, nil, "zeta/some-model:free", priceSourceUnpriced, true}, + {nil, nil, "zeta/z-model", priceSourceUnpriced, false}, + } + for _, c := range cases { + got, ok := byID[c.id] + if !ok { + t.Errorf("model %q missing from catalog", c.id) + continue + } + if got.PriceSource != c.wantSource { + t.Errorf("model %q PriceSource = %q, want %q", c.id, got.PriceSource, c.wantSource) + } + if got.DisplayFree != c.wantDisplayFree { + t.Errorf("model %q DisplayFree = %v, want %v", c.id, got.DisplayFree, c.wantDisplayFree) + } + if !ptrFloat64Equal(got.InputPerMTokUSD, c.wantInputPerM) { + t.Errorf("model %q InputPerMTokUSD = %v, want %v", c.id, derefFloat64(got.InputPerMTokUSD), derefFloat64(c.wantInputPerM)) + } + if !ptrFloat64Equal(got.OutputPerMTokUSD, c.wantOutputPerM) { + t.Errorf("model %q OutputPerMTokUSD = %v, want %v", c.id, derefFloat64(got.OutputPerMTokUSD), derefFloat64(c.wantOutputPerM)) + } + } +} + +// ptrFloat64/derefFloat64/ptrFloat64Equal are small nil-safe *float64 +// helpers for TestAdminCatalog_PriceSourceAndDisplayFree's own table — +// adminCatalogModel's price fields are `*float64` (nil = "no billed +// rate", never a fabricated 0), so the table needs to express and +// compare that nil-ness directly rather than comparing bare float64s. +func ptrFloat64(v float64) *float64 { return &v } + +func derefFloat64(p *float64) float64 { + if p == nil { + return 0 + } + return *p +} + +func ptrFloat64Equal(a, b *float64) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return *a == *b +} + +// TestAdminCatalog_AliasesGroupedByTarget pins each model's Aliases field +// to exactly the configured aliases whose Target names it, sorted. +func TestAdminCatalog_AliasesGroupedByTarget(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.ModelAliases = map[string]string{ + "z-model-b-alias": "zeta/z-model", + "z-model-a-alias": "zeta/z-model", + } + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminCatalogPath, "sk-admin1")) + var got adminCatalogResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + var zModel *adminCatalogModel + for _, p := range got.Providers { + for i := range p.Models { + if p.Models[i].ID == "zeta/z-model" { + zModel = &p.Models[i] + } + } + } + if zModel == nil { + t.Fatal("zeta/z-model missing from catalog") + } + want := []string{"z-model-a-alias", "z-model-b-alias"} + if len(zModel.Aliases) != len(want) || zModel.Aliases[0] != want[0] || zModel.Aliases[1] != want[1] { + t.Errorf("Aliases = %v, want %v (sorted)", zModel.Aliases, want) + } + if len(got.Aliases) != 2 { + t.Errorf("top-level Aliases = %d entries, want 2", len(got.Aliases)) + } +} + +// --- GET /admin/api/consumers --- + +func TestAdminConsumers_GateMatrix(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + cases := []struct { + name string + apiKey string + want int + }{ + {"unauthenticated", "", http.StatusUnauthorized}, + {"non-admin", "sk-alice", http.StatusForbidden}, + {"admin", "sk-admin1", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminConsumersPath, c.apiKey)) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +// TestAdminConsumers_UserSourceAndPersonalGrant exercises source +// (inline/file) and the personal-grant Providers/Models fields end to +// end through GET /admin/api/consumers. +func TestAdminConsumers_UserSourceAndPersonalGrant(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.Users.Inline = append(cfg.Users.Inline, &UserConfig{ + Name: "grantee", Group: "agroup", APIKey: "sk-grantee", + Providers: []string{"alpha"}, Models: []string{"a-model-1"}, + }) + h, gw := newAdminGatewayHandle(t, cfg) + if err := gw.auth.replaceFileUsers([]*UserConfig{ + {Name: "filed", Group: "agroup", APIKey: "sk-filed"}, + }); err != nil { + t.Fatal(err) + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminConsumersPath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminConsumersResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + byName := map[string]adminConsumerUser{} + for _, u := range got.Users { + byName[u.Name] = u + } + + if byName["alice"].Source != "inline" { + t.Errorf(`alice.Source = %q, want "inline"`, byName["alice"].Source) + } + if byName["filed"].Source != "file" { + t.Errorf(`filed.Source = %q, want "file"`, byName["filed"].Source) + } + if !byName["admin1"].Admin { + t.Errorf("admin1.Admin = false, want true") + } + g := byName["grantee"] + if len(g.Providers) != 1 || g.Providers[0] != "alpha" { + t.Errorf("grantee.Providers = %v, want [alpha]", g.Providers) + } + if len(g.Models) != 1 || g.Models[0] != "a-model-1" { + t.Errorf("grantee.Models = %v, want [a-model-1]", g.Models) + } +} + +// TestAdminConsumers_ModelAccessCounts pins adminConsumerGroup's +// ModelAccess/AllowedProviders against the live catalog for a group with +// an explicit provider+model restriction. +func TestAdminConsumers_ModelAccessCounts(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.Groups["agroup"] = &GroupConfig{Providers: []string{"alpha"}, Models: []string{"a-model-1"}} + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminConsumersPath, "sk-admin1")) + var got adminConsumersResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + var agroup *adminConsumerGroup + for i := range got.Groups { + if got.Groups[i].Name == "agroup" { + agroup = &got.Groups[i] + } + } + if agroup == nil { + t.Fatal("agroup missing from consumers response") + } + if agroup.AllowedProviders == nil { + t.Error("AllowedProviders = nil, want non-nil") + } + if len(agroup.AllowedProviders) != 1 || agroup.AllowedProviders[0] != "alpha" { + t.Errorf("AllowedProviders = %v, want [alpha]", agroup.AllowedProviders) + } + alpha, ok := agroup.ModelAccess["alpha"] + if !ok { + t.Fatal("ModelAccess[alpha] missing") + } + if alpha.Allowed != 1 || alpha.Total != 2 { + t.Errorf("ModelAccess[alpha] = %+v, want {Allowed:1 Total:2}", alpha) + } + zeta, ok := agroup.ModelAccess["zeta"] + if !ok { + t.Fatal("ModelAccess[zeta] missing") + } + if zeta.Allowed != 0 || zeta.Total != 1 { + t.Errorf("ModelAccess[zeta] = %+v, want {Allowed:0 Total:1}", zeta) + } + + // zgroup has no restriction: unrestricted access to every provider. + var zgroup *adminConsumerGroup + for i := range got.Groups { + if got.Groups[i].Name == "zgroup" { + zgroup = &got.Groups[i] + } + } + if zgroup == nil { + t.Fatal("zgroup missing") + } + if len(zgroup.AllowedProviders) != 2 { + t.Errorf("zgroup.AllowedProviders = %v, want both providers", zgroup.AllowedProviders) + } +} + +// TestAdminConsumers_UsersFilePathOnly pins the "usersFile path shown, +// contents never" risk note (plan §6): only the configured path appears, +// never any per-user data beyond what userSummary/groupSummary already +// carry. +func TestAdminConsumers_UsersFilePathOnly(t *testing.T) { + t.Parallel() + fp := filepath.Join(t.TempDir(), "users.json") + writeUsersDoc(t, fp, []*UserConfig{{Name: "filed", Group: "agroup", APIKey: "sk-filed"}}, time.Time{}) + + cfg := newAdminTestConfig() + cfg.Users.File = fp + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminConsumersPath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminConsumersResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.UsersFile != fp { + t.Errorf("UsersFile = %q, want %q", got.UsersFile, fp) + } + for _, u := range got.Users { + if u.Name == "filed" && u.Source != "file" { + t.Errorf("filed.Source = %q, want file", u.Source) + } + } +} diff --git a/admin_config.go b/admin_config.go new file mode 100644 index 0000000..bf1374b --- /dev/null +++ b/admin_config.go @@ -0,0 +1,169 @@ +package traefikllmgateway + +import ( + "encoding/json" + "net/http" + "strings" +) + +// adminConfigPath is GET /admin/api/config's route (admin dashboard +// redesign, WP-B) — defined here, alongside the rest of this feature's +// own constants, mirroring adminEventsPath's own precedent (events.go). +const adminConfigPath = "/admin/api/config" + +// redactedSecretPlaceholder replaces a LITERAL secret value redactConfig +// finds (below) — never an "env:"/"file:" indirection, which is kept +// verbatim (redactSecretString's own doc comment). +const redactedSecretPlaceholder = "[redacted]" + +// adminConfigResponse is the full body of GET /admin/api/config: the +// live Config, redacted (redactConfig, below) so no secret — a literal +// API key, Redis password, or embedded URL credential — ever leaves the +// process, plus the SAME construction warnings GET /admin/api/overview +// already exposes (Warnings) and the SAME feature-availability flags +// (Features, admin.go's buildAdminFeatures) every other new admin +// endpoint echoes, so the Config tab's change-helper forms can gate +// their own UI on the identical flags without a second round trip. +type adminConfigResponse struct { + Config map[string]any `json:"config"` + Version string `json:"version"` + Warnings []string `json:"warnings"` + Features adminFeaturesView `json:"features"` +} + +// redactedConfigKeys names every JSON object key redactConfig treats as +// carrying a secret VALUE outright (as opposed to baseURLLikeConfigKeys, +// below, whose value is a URL that may EMBED one): "apiKey" (Provider/ +// UserConfig) and "password" (RedisConfig). Matched by JSON key name +// alone, not by which Go struct it came from — redactConfig walks the +// generic map[string]any json.Marshal produces, with no notion of the +// original struct type, so a future secret-shaped field is covered +// automatically the moment it reuses one of these same JSON tag names. +var redactedConfigKeys = map[string]bool{ + "apiKey": true, + "password": true, +} + +// baseURLLikeConfigKeys names every JSON object key redactConfig passes +// through sanitizeBaseURL (admin.go) instead of the literal/env/file +// redaction rule above: ProviderConfig.BaseURL ("baseUrl") and +// TargetConfig/AgentConfig.URL ("url") — a URL is not itself a secret, +// but an operator can embed one in its userinfo or query string +// (sanitizeBaseURL's own doc comment), exactly the same risk GET +// /admin/api/overview's adminProviderView.BaseURL already guards +// against. +var baseURLLikeConfigKeys = map[string]bool{ + "baseUrl": true, + "url": true, +} + +// redactConfig returns a redacted map[string]any view of cfg, safe to +// serve over GET /admin/api/config (plan §6's security risk: "new +// endpoints expose user/model names... config redaction fuzz-tested"). +// cfg is round-tripped through json.Marshal then json.Unmarshal into a +// generic map, then walked recursively (redactValue, below), so the walk +// needs no knowledge of Config's own nested struct types — a field added +// to any struct in llmgateway.go is redacted automatically the moment it +// round-trips through this walk, purely by its JSON key name matching +// redactedConfigKeys or baseURLLikeConfigKeys. +// +// The marshal/unmarshal round trip can fail only for a Config value that +// is not itself valid JSON in the first place — never reachable for a +// *Gateway's own g.cfg, which newGateway already round-tripped through +// Traefik's own YAML/JSON decoder to construct — so an error here is +// reported, never silently swallowed, but is not expected to fire on any +// real, already-running Gateway (buildAdminConfig, below, degrades +// gracefully rather than 500ing if it ever does). +func redactConfig(cfg *Config) (map[string]any, error) { + raw, err := json.Marshal(cfg) + if err != nil { + return nil, err + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + return nil, err + } + redactValue(m) + return m, nil +} + +// redactValue walks v in place. json.Unmarshal decodes a JSON object +// into map[string]any and a JSON array into []any — the two cases this +// switch handles — so this walk reaches every nesting depth Config's own +// struct tree can produce (Config.Groups, Config.Users.Inline, +// Config.Providers, Config.MCPServers, Config.Agents, Config.ModelMeta, +// Config.Pricing all nest further object/array values) without needing +// its own copy of that struct tree's shape. +func redactValue(v any) { + switch t := v.(type) { + case map[string]any: + for k, val := range t { + s, isString := val.(string) + switch { + case redactedConfigKeys[k] && isString: + t[k] = redactSecretString(s) + case baseURLLikeConfigKeys[k] && isString: + t[k] = sanitizeBaseURL(s) + default: + redactValue(val) + } + } + case []any: + for _, item := range t { + redactValue(item) + } + } +} + +// redactSecretString applies plan §1.3(ix)'s redaction rule to one +// apiKey/password value: empty stays empty (nothing configured, nothing +// to hide), an "env:NAME" or "file:/path" reference is kept verbatim +// (resolveSecret's own doc comment, secrets.go — neither form is the +// secret itself, only where to find it, and an operator reading their +// own config back needs to see which indirection a field actually uses), +// and any other value — a literal secret typed directly into config — is +// replaced with redactedSecretPlaceholder. Mirrors resolveSecret's own +// "env:"/"file:" prefix check exactly, so a value this function keeps is +// always one resolveSecret would also resolve through an indirection, +// never a literal — fuzz-tested (admin_config_test.go) so no generated +// literal secret ever survives this function. +func redactSecretString(s string) string { + switch { + case s == "": + return s + case strings.HasPrefix(s, "env:"), strings.HasPrefix(s, "file:"): + return s + default: + return redactedSecretPlaceholder + } +} + +// buildAdminConfig assembles adminConfigResponse: g.cfg redacted +// (redactConfig, above), the same construction warnings +// configWarningsSnapshot already exposes on GET /admin/api/overview +// (admin.go), and this Gateway's feature-availability flags +// (buildAdminFeatures, admin.go). A redaction failure (redactConfig's +// own doc comment — not expected on any real running Gateway) degrades +// to an empty config map with a warning appended, rather than a 500: +// every other field in the response is still meaningful even if the +// config tree itself could not be built. +func (g *Gateway) buildAdminConfig() adminConfigResponse { + warnings, _ := g.configWarningsSnapshot() + redacted, err := redactConfig(g.cfg) + if err != nil { + redacted = map[string]any{} + warnings = append(warnings, "config redaction failed: "+err.Error()) + } + return adminConfigResponse{ + Config: redacted, + Warnings: warnings, + Features: g.buildAdminFeatures(), + Version: pluginVersion, + } +} + +// serveAdminConfig writes buildAdminConfig's result as JSON. +func (g *Gateway) serveAdminConfig(w http.ResponseWriter) { + setAdminJSONHeaders(w) + _ = json.NewEncoder(w).Encode(g.buildAdminConfig()) +} diff --git a/admin_config_test.go b/admin_config_test.go new file mode 100644 index 0000000..ec2f301 --- /dev/null +++ b/admin_config_test.go @@ -0,0 +1,333 @@ +package traefikllmgateway + +import ( + "encoding/json" + "math/rand" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +// --- gate + shape --- + +func TestAdminConfig_GateMatrix(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + cases := []struct { + name string + apiKey string + want int + }{ + {"unauthenticated", "", http.StatusUnauthorized}, + {"non-admin", "sk-alice", http.StatusForbidden}, + {"admin", "sk-admin1", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminConfigPath, c.apiKey)) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +func TestAdminConfig_Shape(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.Redis = &RedisConfig{Address: "redis:6379", Password: "super-secret-literal"} + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminConfigPath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminConfigResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Version != pluginVersion { + t.Errorf("Version = %q, want %q", got.Version, pluginVersion) + } + if got.Warnings == nil { + t.Error("Warnings = nil, want non-nil (never nil, matching overview's own contract)") + } + redis, ok := got.Config["redis"].(map[string]any) + if !ok { + t.Fatalf("config.redis missing or wrong shape: %+v", got.Config["redis"]) + } + if redis["password"] != redactedSecretPlaceholder { + t.Errorf(`config.redis.password = %v, want %q`, redis["password"], redactedSecretPlaceholder) + } + if strings.Contains(rec.Body.String(), "super-secret-literal") { + t.Error("literal redis password leaked into the response body") + } +} + +// --- redactConfig / redactValue unit coverage --- + +func TestRedactConfig_LiteralAndIndirectSecrets(t *testing.T) { + t.Parallel() + // Every string below is a synthetic test fixture, not a real + // credential; this test exists specifically to assert none of them + // survive redactConfig. The userinfo URL is built via net/url rather + // than written as one literal, so secret scanners do not flag it. + credentialBaseURL := (&url.URL{Scheme: "https", User: url.UserPassword("token", "s3cr3t"), Host: "openai.invalid"}).String() + cfg := &Config{ + Providers: map[string]*ProviderConfig{ + "openai": {Type: "openai", BaseURL: credentialBaseURL, APIKey: "literal-key"}, // #nosec G101 -- test fixture, not a real secret + "local": {Type: "openai", APIKey: "env:OPENAI_KEY"}, // #nosec G101 -- test fixture, not a real secret + "file": {Type: "openai", APIKey: "file:/etc/secrets/key"}, + "empty": {Type: "openai", APIKey: ""}, + }, + Redis: &RedisConfig{Address: "redis:6379", Password: "redis-literal-pw"}, // #nosec G101 -- test fixture, not a real secret + Users: &UsersConfig{ + File: "/etc/llmgw/users.json", + Inline: []*UserConfig{ + {Name: "alice", Group: "eng", APIKey: "alice-literal-key"}, + }, + }, + MCPServers: map[string]*TargetConfig{"fetch": {URL: "https://user:pw@mcp.internal/fetch"}}, // #nosec G101 -- test fixture, not a real secret + Agents: map[string]*AgentConfig{"planner": {URL: "https://agent.internal?api-key=abc123"}}, + } + + redacted, err := redactConfig(cfg) + if err != nil { + t.Fatalf("redactConfig: %v", err) + } + raw, err := json.Marshal(redacted) + if err != nil { + t.Fatalf("marshal redacted: %v", err) + } + body := string(raw) + + for _, leaked := range []string{"literal-key", "redis-literal-pw", "alice-literal-key", "s3cr3t", "user:pw", "abc123"} { + if strings.Contains(body, leaked) { + t.Errorf("redacted config still contains %q: %s", leaked, body) + } + } + for _, kept := range []string{"env:OPENAI_KEY", "file:/etc/secrets/key", "/etc/llmgw/users.json"} { + if !strings.Contains(body, kept) { + t.Errorf("redacted config dropped %q, want it kept verbatim: %s", kept, body) + } + } + + providers, ok := redacted["providers"].(map[string]any) + if !ok { + t.Fatalf("providers missing or wrong shape") + } + openai, ok := providers["openai"].(map[string]any) + if !ok { + t.Fatalf("providers.openai missing or wrong shape") + } + if openai["apiKey"] != redactedSecretPlaceholder { + t.Errorf(`providers.openai.apiKey = %v, want %q`, openai["apiKey"], redactedSecretPlaceholder) + } + if empty, emptyOK := providers["empty"].(map[string]any); !emptyOK || empty["apiKey"] != "" { + t.Errorf(`providers.empty.apiKey = %v, want "" (empty stays empty)`, providers["empty"]) + } + local, ok := providers["local"].(map[string]any) + if !ok || local["apiKey"] != "env:OPENAI_KEY" { + t.Errorf(`providers.local.apiKey = %v, want "env:OPENAI_KEY" kept verbatim`, providers["local"]) + } + baseURL, _ := openai["baseUrl"].(string) + if strings.Contains(baseURL, "s3cr3t") || strings.Contains(baseURL, "token") { + t.Errorf("providers.openai.baseUrl = %q, want userinfo stripped by sanitizeBaseURL", baseURL) + } +} + +// TestRedactSecretString_Table pins redactSecretString's three cases +// directly, independent of the full Config walk above. +func TestRedactSecretString_Table(t *testing.T) { + t.Parallel() + cases := []struct{ in, want string }{ + {"", ""}, + {"env:FOO", "env:FOO"}, + {"file:/path/to/secret", "file:/path/to/secret"}, + {"plain-literal", redactedSecretPlaceholder}, + {"envelope-not-a-prefix-match", redactedSecretPlaceholder}, // "env" substring, not "env:" prefix + } + for _, c := range cases { + if got := redactSecretString(c.in); got != c.want { + t.Errorf("redactSecretString(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestSanitizeBaseURL_ParseFailure_NeverLeaksRawValue is P5 (admin +// dashboard redesign verify round): sanitizeBaseURL used to return raw +// verbatim whenever url.Parse failed, on the theory that a value failing +// to parse "names no scheme/host/userinfo net/url can identify" — true +// for genuine garbage, but url.Parse fails on plenty of real-looking URLs +// too. Two real vectors: a password containing an unescaped '/' (net/url +// reads the remainder as an invalid port) and a malformed '%' escape +// anywhere in the string. Both used to leak the embedded credential +// verbatim; both must now return the fixed placeholder instead. +func TestSanitizeBaseURL_ParseFailure_NeverLeaksRawValue(t *testing.T) { + t.Parallel() + cases := []struct { + name string + raw string + }{ + {"password containing unescaped slash (invalid port)", "https://user:ab/cdSECRET1@host.invalid/v1"}, + {"malformed percent escape", "https://user:50%zzSECRET2@host.invalid/v1"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := url.Parse(c.raw); err == nil { + t.Fatalf("fixture %q parses without error — not a real repro of the parse-failure path", c.raw) + } + got := sanitizeBaseURL(c.raw) + if got != unparseableBaseURLPlaceholder { + t.Errorf("sanitizeBaseURL(%q) = %q, want the placeholder %q", c.raw, got, unparseableBaseURLPlaceholder) + } + }) + } +} + +// TestAdminConfigAndOverview_UnparseableBaseURL_NeverLeaksCredentials +// drives P5's two vectors through the real HTTP surface: GET +// /admin/api/config (redactConfig -> sanitizeBaseURL) and GET +// /admin/api/overview (buildAdminOverview's own provider view) both used +// to echo the embedded credential verbatim whenever a provider's baseUrl +// failed to parse. +func TestAdminConfigAndOverview_UnparseableBaseURL_NeverLeaksCredentials(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + cfg.Providers["alpha"].BaseURL = "https://user:ab/cdSECRET1@host.invalid/v1" + cfg.Providers["zeta"].BaseURL = "https://user:50%zzSECRET2@host.invalid/v1" + h, _ := newAdminGatewayHandle(t, cfg) + + for _, path := range []string{adminConfigPath, adminOverviewPath} { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, path, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("%s status = %d, want 200, body=%s", path, rec.Code, rec.Body.String()) + } + body := rec.Body.String() + for _, secret := range []string{"SECRET1", "SECRET2"} { + if strings.Contains(body, secret) { + t.Errorf("%s response leaks %q (unparseable baseUrl echoed verbatim instead of sanitizeBaseURL's placeholder): %s", path, secret, body) + } + } + } +} + +// --- fuzz: no random literal secret ever survives redaction --- + +// TestRedactConfig_FuzzNoLiteralSecretSurvives generates many random +// "secret" strings (varying length, charset, and shape — including some +// that happen to start with substrings resembling "env"/"file" without +// the ":" prefix redactSecretString actually checks for) and asserts each +// literal one is fully absent from the redacted, re-marshaled output — +// the property the task explicitly calls for ("Config redaction must be +// fuzz-tested so no literal secret ever appears"). +func TestRedactConfig_FuzzNoLiteralSecretSurvives(t *testing.T) { + t.Parallel() + rng := rand.New(rand.NewSource(20260923)) // #nosec G404 -- deterministic test-fixture generator, not security-sensitive + const iterations = 500 + + for i := 0; i < iterations; i++ { + secret := randomSecretLikeString(rng) + cfg := &Config{ + // BaseURL/URL userinfo redaction (sanitizeBaseURL) is covered + // separately by TestRedactConfig_LiteralAndIndirectSecrets — this + // fuzz loop targets apiKey/password specifically, the fields + // redactSecretString's literal/env/file rule directly governs, so + // a fuzzed secret containing URL-structural characters (":", "/") + // can never produce a false failure from URL re-parsing instead of + // from redaction itself. + Providers: map[string]*ProviderConfig{"p": {Type: "openai", APIKey: secret}}, + Redis: &RedisConfig{Address: "redis:6379", Password: secret}, + Users: &UsersConfig{Inline: []*UserConfig{ + {Name: "u", Group: "g", APIKey: secret}, + }}, + } + + redacted, err := redactConfig(cfg) + if err != nil { + t.Fatalf("iteration %d: redactConfig: %v", i, err) + } + + // Checked field-by-field, not as a whole-body substring search: + // a short random secret (e.g. "u") can coincidentally collide with + // an unrelated STRUCTURAL literal already in the config (a user + // name, a group name, a provider key) that legitimately survives + // redaction — a substring search over the whole marshaled body + // would then false-positive on that collision instead of on an + // actual secret leak. Reading the exact redacted field back is + // both more precise and a stronger assertion: the field's value + // must equal secret's indirection form exactly, not merely "not + // contain the raw secret somewhere in the JSON". + providers, _ := redacted["providers"].(map[string]any) + p, _ := providers["p"].(map[string]any) + redis, _ := redacted["redis"].(map[string]any) + users, _ := redacted["users"].(map[string]any) + inline, _ := users["inline"].([]any) + var userEntry map[string]any + if len(inline) > 0 { + userEntry, _ = inline[0].(map[string]any) + } + + isIndirect := secret == "" || strings.HasPrefix(secret, "env:") || strings.HasPrefix(secret, "file:") + wantVal := secret + if !isIndirect { + wantVal = redactedSecretPlaceholder + } + if got, _ := p["apiKey"].(string); got != wantVal { + t.Fatalf("iteration %d: secret %q -> providers.p.apiKey = %q, want %q", i, secret, got, wantVal) + } + if got, _ := redis["password"].(string); got != wantVal { + t.Fatalf("iteration %d: secret %q -> redis.password = %q, want %q", i, secret, got, wantVal) + } + if got, _ := userEntry["apiKey"].(string); got != wantVal { + t.Fatalf("iteration %d: secret %q -> users.inline[0].apiKey = %q, want %q", i, secret, got, wantVal) + } + + // Belt-and-braces: the raw secret bytes must never appear ANYWHERE + // in the re-marshaled JSON either, unless it's short enough to + // plausibly collide with an unrelated structural literal (name, + // group, provider key) — only enforced for a long-enough secret, + // where a coincidental collision is astronomically unlikely. + if !isIndirect && len(secret) >= 8 { + raw, err := json.Marshal(redacted) + if err != nil { + t.Fatalf("iteration %d: marshal: %v", i, err) + } + if strings.Contains(string(raw), secret) { + t.Fatalf("iteration %d: literal secret %q survived redaction: %s", i, secret, raw) + } + } + } +} + +// randomSecretLikeString generates a pseudo-random string a fuzz +// iteration treats as a secret: length 0..63, drawn from a charset wide +// enough to include characters a real API key/password/URL-userinfo +// value might carry (alnum plus a handful of URL/shell-adjacent +// punctuation), occasionally producing an "env:"/"file:"-prefixed value +// on purpose so both code paths (kept verbatim vs. redacted) get +// exercised across the run. +func randomSecretLikeString(rng *rand.Rand) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_./:+=" + n := rng.Intn(64) + b := make([]byte, n) + for i := range b { + b[i] = charset[rng.Intn(len(charset))] + } + s := string(b) + switch rng.Intn(8) { + case 0: + return "env:" + s + case 1: + return "file:/" + s + default: + return s + } +} diff --git a/admin_perf_test.go b/admin_perf_test.go new file mode 100644 index 0000000..728ebab --- /dev/null +++ b/admin_perf_test.go @@ -0,0 +1,427 @@ +package traefikllmgateway + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// --- histogramQuantile --- + +func TestHistogramQuantile_NoObservationsReturnsZeroNotOverflow(t *testing.T) { + t.Parallel() + bounds := []float64{1, 2, 3} + counts := []int64{0, 0, 0, 0} // 3 buckets + overflow, all empty + ms, overflow := histogramQuantile(bounds, counts, 0.5) + if ms != 0 || overflow { + t.Errorf("histogramQuantile(empty) = (%v, %v), want (0, false)", ms, overflow) + } +} + +func TestHistogramQuantile_InterpolatesWithinBucket(t *testing.T) { + t.Parallel() + bounds := []float64{1, 2, 3} + // Every observation lands in bucket 0 (0, 1] seconds. + counts := []int64{10, 0, 0, 0} + ms, overflow := histogramQuantile(bounds, counts, 0.5) + if overflow { + t.Fatal("overflow = true, want false") + } + want := 500.0 // midpoint of (0, 1]s in ms + if math.Abs(ms-want) > 1e-9 { + t.Errorf("histogramQuantile(bucket0, q=0.5) = %v ms, want %v", ms, want) + } +} + +func TestHistogramQuantile_SpansTwoBuckets(t *testing.T) { + t.Parallel() + bounds := []float64{1, 2, 3} + // 5 observations in (0,1], 5 in (1,2]: the p50 rank (target=5) lands + // exactly at the boundary between the two buckets. + counts := []int64{5, 5, 0, 0} + ms, overflow := histogramQuantile(bounds, counts, 0.5) + if overflow { + t.Fatal("overflow = true, want false") + } + if math.Abs(ms-1000) > 1e-9 { + t.Errorf("histogramQuantile(boundary) = %v ms, want 1000", ms) + } + // p90: target=9, cum after bucket0=5, falls in bucket1 (1,2], frac=(9-5)/5=0.8 + p90, overflow := histogramQuantile(bounds, counts, 0.9) + if overflow { + t.Fatal("overflow = true, want false") + } + want := (1 + 0.8*(2-1)) * 1000 + if math.Abs(p90-want) > 1e-9 { + t.Errorf("histogramQuantile(q=0.9) = %v ms, want %v", p90, want) + } +} + +func TestHistogramQuantile_OverflowBucket(t *testing.T) { + t.Parallel() + bounds := []float64{1, 2, 3} + // Every observation is past the largest bound. + counts := []int64{0, 0, 0, 7} + ms, overflow := histogramQuantile(bounds, counts, 0.99) + if !overflow { + t.Fatal("overflow = false, want true") + } + if ms != 3000 { + t.Errorf("histogramQuantile(overflow) = %v ms, want 3000 (largest bound in ms)", ms) + } +} + +func TestHistogramQuantile_RealLatencyBounds(t *testing.T) { + t.Parallel() + n := len(latencyBucketBounds) + 1 + counts := make([]int64, n) + counts[3] = 1 // latencyBucketBounds[3] == 1 (index for the 1s bound) + ms, overflow := histogramQuantile(latencyBucketBounds, counts, 0.5) + if overflow { + t.Fatal("overflow = true, want false") + } + // Single observation in bucket (0.5, 1]s: midpoint interpolation. + wantLow, wantHigh := 500.0, 1000.0 + if ms < wantLow || ms > wantHigh { + t.Errorf("histogramQuantile(real bounds) = %v ms, want in [%v, %v]", ms, wantLow, wantHigh) + } +} + +// --- HTTP endpoint --- + +func TestAdminPerformance_GateMatrix(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + path := adminPerformancePath + "?kind=provider&window=day" + cases := []struct { + name string + apiKey string + want int + }{ + {"unauthenticated", "", http.StatusUnauthorized}, + {"non-admin", "sk-alice", http.StatusForbidden}, + {"admin", "sk-admin1", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, path, c.apiKey)) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +func TestAdminPerformance_ParamValidation(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + cases := []struct { + name string + qs string + want int + }{ + {"unknown kind", "?kind=bogus&window=day", http.StatusBadRequest}, + {"month window rejected", "?kind=provider&window=month", http.StatusBadRequest}, + {"invalid series", "?kind=provider&window=day&series=2", http.StatusBadRequest}, + {"series without exactly one id", "?kind=provider&window=day&series=1&id=alpha&id=zeta", http.StatusBadRequest}, + {"series with zero ids", "?kind=provider&window=day&series=1", http.StatusBadRequest}, + // P1 fix (admin dashboard redesign verify round): exactly one raw + // id given (satisfying the len(rawIDs)!=1 check above) that does + // not resolve to a real provider/model — ids ends up empty, and + // ids[0] used to panic (recovered as a 500 by errors.go, but still + // a logged panic) instead of this 400. + {"series with unknown provider id", "?kind=provider&window=day&series=1&id=nope", http.StatusBadRequest}, + {"series with unknown model id", "?kind=model&window=day&series=1&id=nope/x", http.StatusBadRequest}, + {"valid provider", "?kind=provider&window=day", http.StatusOK}, + {"valid model", "?kind=model&window=hour", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPerformancePath+c.qs, "sk-admin1")) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +func TestAdminPerformance_TooManyIDsIs400(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + q := "?kind=provider&window=day" + for i := 0; i <= adminPerfMaxIDs; i++ { + q += "&id=alpha" + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPerformancePath+q, "sk-admin1")) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (over adminPerfMaxIDs)", rec.Code) + } +} + +// TestAdminPerformance_AttemptsAlwaysLatencyGated seeds provider attempt/ +// fail counters (always-on) and latency buckets (opt-in), then confirms: +// with stats.latency OFF, Attempts/Failures are populated but every +// latency field stays zero/nil and LatencyEnabled is false; with it ON, +// the percentile fields populate too. +func TestAdminPerformance_AttemptsAlwaysLatencyGated(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + store := &countingMultiStore{values: map[string]int64{ + windowKey(kindProvider, "alpha", metricProvAttempt, windowDay, fixedNow): 10, + windowKey(kindProvider, "alpha", metricProvFail, windowDay, fixedNow): 2, + windowKey(kindProvider, "alpha", latencyDurationMetric(3), windowDay, fixedNow): 4, // bucket for bound index 3 + }} + + // Latency OFF (default). + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPerformancePath+"?kind=provider&window=day&id=alpha", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminPerfResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.LatencyEnabled { + t.Error("LatencyEnabled = true, want false (stats.latency off)") + } + if len(got.Rows) != 1 { + t.Fatalf("Rows = %+v, want exactly 1", got.Rows) + } + row := got.Rows[0] + if row.Attempts != 10 || row.Failures != 2 { + t.Errorf("Attempts/Failures = %d/%d, want 10/2 (always-on)", row.Attempts, row.Failures) + } + if row.P50Ms != nil || row.Count != 0 { + t.Errorf("P50Ms/Count = %v/%d, want nil/0 with latency stats off", row.P50Ms, row.Count) + } + + // Latency ON. + cfg2 := newAdminTestConfig() + cfg2.Admin.Stats = &AdminStatsConfig{Latency: true} + h2, gw2 := newAdminGatewayHandle(t, cfg2) + gw2.limiter = newLimiter(store, true) + gw2.limiter.nowFn = func() time.Time { return fixedNow } + + rec2 := httptest.NewRecorder() + h2.ServeHTTP(rec2, adminRequest(http.MethodGet, adminPerformancePath+"?kind=provider&window=day&id=alpha", "sk-admin1")) + if rec2.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec2.Code, rec2.Body.String()) + } + var got2 adminPerfResponse + if err := json.Unmarshal(rec2.Body.Bytes(), &got2); err != nil { + t.Fatalf("decode: %v", err) + } + if !got2.LatencyEnabled { + t.Error("LatencyEnabled = false, want true (stats.latency on)") + } + if len(got2.Rows) != 1 { + t.Fatalf("Rows = %+v, want exactly 1", got2.Rows) + } + row2 := got2.Rows[0] + if row2.Count != 4 { + t.Errorf("Count = %d, want 4", row2.Count) + } + if row2.P50Ms == nil { + t.Error("P50Ms = nil, want populated with latency observations present") + } +} + +// TestAdminPerformance_ModelKindHasNoTimeoutsFailovers pins that +// kind=model rows never populate Timeouts/Failovers (plan §1.2: no +// per-model scope tracks either). +func TestAdminPerformance_ModelKindHasNoTimeoutsFailovers(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + store := &countingMultiStore{values: map[string]int64{ + windowKey(kindProviderModel, "alpha/a-model-1", metricProvAttempt, windowDay, fixedNow): 3, + }} + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPerformancePath+"?kind=model&window=day&id=alpha/a-model-1", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminPerfResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Rows) != 1 { + t.Fatalf("Rows = %+v, want exactly 1", got.Rows) + } + if got.Rows[0].Timeouts != 0 || got.Rows[0].Failovers != 0 { + t.Errorf("Timeouts/Failovers = %d/%d, want 0/0 for kind=model", got.Rows[0].Timeouts, got.Rows[0].Failovers) + } + if got.Rows[0].Attempts != 3 { + t.Errorf("Attempts = %d, want 3", got.Rows[0].Attempts) + } +} + +// TestAdminPerformance_DefaultProviderIDsAreAllConfigured pins the +// "provider default = all configured" rule (plan §1.3(vi)). +func TestAdminPerformance_DefaultProviderIDsAreAllConfigured(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPerformancePath+"?kind=provider&window=day", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminPerfResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Rows) != 2 { + t.Fatalf("Rows = %+v, want 2 (both configured providers)", got.Rows) + } +} + +// TestAdminPerformance_SeriesModeBucketPerPoint pins series=1's shape: +// Points has exactly `span` entries, one per bucket, ID set to the +// bucket label, Rows left empty. +func TestAdminPerformance_SeriesModeBucketPerPoint(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + yesterday := fixedNow.AddDate(0, 0, -1) + store := &countingMultiStore{values: map[string]int64{ + windowKey(kindProvider, "alpha", metricProvAttempt, windowDay, fixedNow): 5, + windowKey(kindProvider, "alpha", metricProvAttempt, windowDay, yesterday): 9, + }} + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPerformancePath+"?kind=provider&window=day&span=2&series=1&id=alpha", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminPerfResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Rows) != 0 { + t.Errorf("Rows = %+v, want empty in series mode", got.Rows) + } + if len(got.Points) != 2 || len(got.Buckets) != 2 { + t.Fatalf("Points/Buckets = %d/%d, want 2/2", len(got.Points), len(got.Buckets)) + } + if got.Points[0].ID != got.Buckets[0] || got.Points[1].ID != got.Buckets[1] { + t.Errorf("Points ID mismatch with Buckets: points=%+v buckets=%v", got.Points, got.Buckets) + } + if got.Points[0].Attempts != 9 || got.Points[1].Attempts != 5 { + t.Errorf("Points attempts = [%d, %d], want [9, 5] (oldest first)", got.Points[0].Attempts, got.Points[1].Attempts) + } + + // "rows" must always be present in the raw JSON, never omitted/null, + // even in series=1 mode where it is semantically empty — a client + // that always does `for (const r of resp.rows)` without a null check + // must never see this one field be the exception. + if !strings.Contains(rec.Body.String(), `"rows":[]`) { + t.Errorf("series=1 response body does not contain a non-null empty \"rows\":[] field: %s", rec.Body.String()) + } +} + +func TestAdminPerformance_StoreDownIs503(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(alwaysErrStore{}, false) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPerformancePath+"?kind=provider&window=day", "sk-admin1")) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestAdminPerformance_SeriesModeLatencyOn_BatchesIntoOneRoundTrip is P6 +// (admin dashboard redesign verify round): perfSeries used to read every +// metric via its OWN storeGetMulti round trip — a provider's series=1 with +// latency on cost 4 core + 28 latency metrics = 32 sequential round trips. +// They now batch into one chunked pipeline: (4+28)*48 = 1,536 keys, under +// usageModelsChunkKeys (1,600) — exactly 1 round trip. +func TestAdminPerformance_SeriesModeLatencyOn_BatchesIntoOneRoundTrip(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + store := &countingMultiStore{values: map[string]int64{}} + cfg := newAdminTestConfig() + cfg.Admin.Stats = &AdminStatsConfig{Latency: true} + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminPerformancePath+"?kind=provider&window=hour&span=48&series=1&id=alpha", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + if store.getMultiCalls != 1 { + t.Errorf("getMultiCalls = %d, want 1 (batched into one chunked pipeline, not 32 sequential round trips)", store.getMultiCalls) + } +} + +// TestAdminUsageModels_DetailKeyCap_AccountsForExtraMetrics is P7 (admin +// dashboard redesign verify round): this cap used to check len(ids)*span +// alone, regardless of detail mode — but detail mode reads up to 7 +// metrics per id (req/tokin/tokout/cost + r402 + chit/csave when cache is +// configured), not the 1 the plain check assumed. A catalog/span +// combination whose SINGLE-metric size fits under adminMaxKeysPerRequest +// but whose true 7-metric detail read does not must 400, and the +// identical catalog/span in non-detail mode (truly 1 metric) must still +// be allowed through. +func TestAdminUsageModels_DetailKeyCap_AccountsForExtraMetrics(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + models := make([]string, 200) + for i := range models { + models[i] = fmt.Sprintf("m%03d", i) + } + cfg.Providers["alpha"].Models = models + cfg.Cache = CacheConfig{Enabled: true, TTL: "1m"} + ln := newBehavioralRedisServer(t) + cfg.Redis = &RedisConfig{Address: ln.Addr().String()} + h, _ := newAdminGatewayHandle(t, cfg) + + // 201 catalog ids (200 alpha + 1 zeta) * span 48 = 9,648 keys for a + // single metric — under adminMaxKeysPerRequest (64,000) alone, but + // 9,648*7 = 67,536 once detail mode's true metric count is accounted + // for, which is over it. + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageModelsPath+"?metric=req&window=hour&span=48&detail=1", "sk-admin1")) + if rec.Code != http.StatusBadRequest { + t.Errorf("detail status = %d, want 400 (range too large once detail mode's extra metrics are accounted for)", rec.Code) + } + + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, adminRequest(http.MethodGet, adminUsageModelsPath+"?metric=req&window=hour&span=48", "sk-admin1")) + if rec2.Code != http.StatusOK { + t.Errorf("non-detail status = %d, want 200 (same catalog/span, single metric, must not be rejected)", rec2.Code) + } +} diff --git a/admin_series_test.go b/admin_series_test.go new file mode 100644 index 0000000..ea298f9 --- /dev/null +++ b/admin_series_test.go @@ -0,0 +1,247 @@ +package traefikllmgateway + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// --- gate --- + +func TestAdminUsageSeries_GateMatrix(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + path := adminUsageSeriesPath + "?scope=total&metric=req&window=day" + cases := []struct { + name string + apiKey string + want int + }{ + {"unauthenticated", "", http.StatusUnauthorized}, + {"non-admin", "sk-alice", http.StatusForbidden}, + {"admin", "sk-admin1", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, path, c.apiKey)) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +// --- param validation --- + +func TestAdminUsageSeries_ParamValidation(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + cases := []struct { + name string + qs string + want int + }{ + {"no scope", "?metric=req&window=day", http.StatusBadRequest}, + {"unknown window", "?scope=total&metric=req&window=fortnight", http.StatusBadRequest}, + {"unknown metric", "?scope=total&metric=bogus&window=day", http.StatusBadRequest}, + {"invalid span", "?scope=total&metric=req&window=day&span=0", http.StatusBadRequest}, + {"invalid offset", "?scope=total&metric=req&window=day&span=30&offset=10", http.StatusBadRequest}, + {"malformed scope prefix", "?scope=bogus&metric=req&window=day", http.StatusOK}, // skipped, not fatal + {"metric invalid for scope kind", "?scope=provider:alpha&metric=req&window=day", http.StatusBadRequest}, + {"valid", "?scope=total&metric=req&window=day", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageSeriesPath+c.qs, "sk-admin1")) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +func TestAdminUsageSeries_TooManyScopesIs400(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + q := "?metric=req&window=day" + for i := 0; i <= adminSeriesMaxScopes; i++ { + q += "&scope=total" + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageSeriesPath+q, "sk-admin1")) + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400 (over adminSeriesMaxScopes)", rec.Code) + } +} + +// TestAdminUsageSeries_UnknownScopeSkippedNotFailed pins the "skip, +// don't fail the whole request" contract for an unrecognized scope id +// (parseSeriesScope ok but no matching user/group/model/provider). +func TestAdminUsageSeries_UnknownScopeSkippedNotFailed(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageSeriesPath+"?scope=user:ghost&scope=total&metric=req&window=day", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminSeriesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Series) != 1 || got.Series[0].Scope != "total" { + t.Errorf("Series = %+v, want exactly [total]", got.Series) + } + if len(got.Unknown) != 1 || got.Unknown[0] != "user:ghost" { + t.Errorf("Unknown = %v, want [user:ghost]", got.Unknown) + } +} + +// TestAdminUsageSeries_BucketAlignmentWithOffset seeds two adjacent day +// buckets with distinct known values and asserts an offset=1 request +// reads the OLDER bucket, not the current one — the core offset +// contract every stats_read.go endpoint shares. +func TestAdminUsageSeries_BucketAlignmentWithOffset(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + yesterday := fixedNow.AddDate(0, 0, -1) + + store := &countingMultiStore{values: map[string]int64{ + windowKey(totalScopeKind, totalScopeID, metricReq, windowDay, fixedNow): 111, + windowKey(totalScopeKind, totalScopeID, metricReq, windowDay, yesterday): 222, + }} + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageSeriesPath+"?scope=total&metric=req&window=day&span=1&offset=1", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminSeriesResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Offset != 1 { + t.Errorf("Offset = %d, want 1", got.Offset) + } + if len(got.Series) != 1 || len(got.Series[0].Points) != 1 || got.Series[0].Points[0] != 222 { + t.Errorf("Series = %+v, want one point = 222 (yesterday's bucket)", got.Series) + } + if len(got.Buckets) != 1 || got.Buckets[0] != bucketFor(yesterday, windowDay) { + t.Errorf("Buckets = %v, want [%s]", got.Buckets, bucketFor(yesterday, windowDay)) + } +} + +// TestAdminUsageSeries_StoreDownIs503 pins the 503 contract via a store +// that always errors (fail-closed path). +func TestAdminUsageSeries_StoreDownIs503(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(alwaysErrStore{}, false) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageSeriesPath+"?scope=total&metric=req&window=day", "sk-admin1")) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestAdminUsageSeries_LatchedFailOpenIs503 pins the "fail-open serves +// zeros from the fallback, but configuredStoreDown must still answer 503" +// rule every admin stats reader in this package shares (currentUsage's +// own doc comment, limits.go): a store that errors, with failOpen=true, +// must NOT silently render a fail-open zero-series as real data. +func TestAdminUsageSeries_LatchedFailOpenIs503(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(alwaysErrStore{}, true) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageSeriesPath+"?scope=total&metric=req&window=day", "sk-admin1")) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503 (latched store-down, not a fail-open zero-series), body=%s", rec.Code, rec.Body.String()) + } +} + +// TestAdminUsageSeries_ProviderAndProvmodelScopes exercises the two scope +// kinds unique to this endpoint (parseSeriesScope/seriesScopeExists), +// beyond what GET /admin/api/usage/history ever accepted. +func TestAdminUsageSeries_ProviderAndProvmodelScopes(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + cases := []struct { + name string + qs string + want int + }{ + {"known provider", "?scope=provider:alpha&metric=attempt&window=day", http.StatusOK}, + {"unknown provider", "?scope=provider:ghost&metric=attempt&window=day", http.StatusOK}, // skipped -> unknown + {"known provmodel", "?scope=provmodel:alpha/a-model-1&metric=attempt&window=day", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageSeriesPath+c.qs, "sk-admin1")) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +// TestAdminUsageSeries_ChunksAtUsageModelsChunkKeys is P6 (admin +// dashboard redesign verify round): this endpoint's own storeGetMulti +// used to build ONE unbounded pipeline for the whole request regardless +// of how many scopes*span keys that worked out to; it is now chunked at +// usageModelsChunkKeys keys per round trip (chunkedGetMulti, limits.go). +// adminSeriesMaxScopes repeated "total" scopes (a scope kind with no +// existence check, so this needs no catalog/user fixture) at the maximum +// hour span (48) is 100*48 = 4,800 keys — 3 chunked round trips, never 1. +func TestAdminUsageSeries_ChunksAtUsageModelsChunkKeys(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + store := &countingMultiStore{values: map[string]int64{}} + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + qs := "?metric=req&window=hour&span=48" + for i := 0; i < adminSeriesMaxScopes; i++ { + qs += "&scope=total" + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageSeriesPath+qs, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + totalKeys := adminSeriesMaxScopes * 48 + wantCalls := (totalKeys + usageModelsChunkKeys - 1) / usageModelsChunkKeys + if store.getMultiCalls != wantCalls { + t.Errorf("getMultiCalls = %d, want %d (%d keys chunked at %d/call)", store.getMultiCalls, wantCalls, totalKeys, usageModelsChunkKeys) + } + if store.getMultiCalls <= 1 { + t.Error("want more than 1 round trip — otherwise this test proves nothing about chunking") + } +} diff --git a/admin_test.go b/admin_test.go index 45f1839..d885882 100644 --- a/admin_test.go +++ b/admin_test.go @@ -2712,6 +2712,197 @@ func TestAdminUsageModels_SpanBounds(t *testing.T) { } } +// TestAdminUsageModels_Offset pins GET /admin/api/usage/models' offset +// parameter (admin dashboard redesign, WP-B): with offset=1, the ranking +// reads the PREVIOUS day's bucket, not today's, and echoes Offset back. +func TestAdminUsageModels_Offset(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + + fixedNow := time.Date(2026, 8, 20, 12, 30, 0, 0, time.UTC) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + seedModelCounter(gw, "alpha/a-model-1", metricReq, windowDay, fixedNow, 5) + gw.limiter.incrCounter(kindModel, "alpha/a-model-1", metricReq, windowDay, historyStepBack(fixedNow, windowDay, 1), 42, dayWindowTTL) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageModelsPath+"?metric=req&window=day&span=1&offset=1", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminUsageModelsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Offset != 1 { + t.Errorf("Offset = %d, want 1", got.Offset) + } + if len(got.Models) != 1 || got.Models[0].ID != "alpha/a-model-1" || got.Models[0].Value != 42 { + t.Errorf("models = %+v, want [alpha/a-model-1: 42] (yesterday's bucket, not today's 5)", got.Models) + } +} + +// TestAdminUsageModels_OffsetOutOfRangeIs400 pins the "0..historyMaxSpan-span" +// bound (parseStatsOffset, stats_read.go). +func TestAdminUsageModels_OffsetOutOfRangeIs400(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + max := historyMaxSpan(windowDay) + cases := []struct { + name string + qs string + want int + }{ + {"negative offset", "?metric=req&window=day&span=1&offset=-1", http.StatusBadRequest}, + {"offset exceeds max-span", fmt.Sprintf("?metric=req&window=day&span=%d&offset=1", max), http.StatusBadRequest}, + {"offset not a number", "?metric=req&window=day&span=1&offset=lots", http.StatusBadRequest}, + {"offset at max-span boundary", fmt.Sprintf("?metric=req&window=day&span=1&offset=%d", max-1), http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageModelsPath+c.qs, "sk-admin1")) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +// TestAdminUsageModels_Detail_OffsetAndCacheExtras pins detail=1's new +// cacheHits/cacheSavedMicroUsd/r402/priceSource fields (admin dashboard +// redesign, WP-B): present only when features.cacheStats (the response +// cache is configured). +func TestAdminUsageModels_Detail_OffsetAndCacheExtras(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + + fixedNow := time.Date(2026, 8, 20, 12, 30, 0, 0, time.UTC) + gw.limiter.nowFn = func() time.Time { return fixedNow } + seedModelCounter(gw, "alpha/a-model-1", metricReq, windowDay, fixedNow, 5) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageModelsPath+"?metric=req&window=day&detail=1", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminUsageModelsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + var row *adminUsageModelEntryView + for i := range got.Models { + if got.Models[i].ID == "alpha/a-model-1" { + row = &got.Models[i] + } + } + if row == nil { + t.Fatalf("alpha/a-model-1 missing from detail ranking: %+v", got.Models) + } + if row.PriceSource == "" { + t.Error("PriceSource = \"\", want populated in detail mode") + } + // No cache configured in newAdminTestConfig: chit/csave extras must + // stay nil. R402 is unrelated to cache configuration (P2 fix, admin + // dashboard redesign verify round: it used to be folded into the same + // cache-gated read as chit/csave, so it wrongly went nil too whenever + // cache was unconfigured) and must always be present in detail mode. + if row.CacheHits != nil || row.CacheSavedMicroUSD != nil { + t.Errorf("cache extras = (%v, %v), want both nil (cache not configured)", row.CacheHits, row.CacheSavedMicroUSD) + } + if row.R402 == nil { + t.Error("R402 = nil, want populated in detail mode regardless of cache configuration") + } +} + +// TestAdminUsageModelsDetail_R402AndCacheExtras_WindowAware is P2 (admin +// dashboard redesign verify round): r402 is read over the SAME window the +// caller asked for whenever r402 actually has a bucket there (accountWith's +// own recordUnpriced402 writes a model's r402 at BOTH hour and day, +// limits.go) — never forced to day regardless of the caller's window, and +// never gated on cache configuration at all (unrelated feature). chit/ +// csave stay day-bucket-only (recordCacheHit never writes an hour bucket +// for a model): window=day and window=month (converted to the exact +// covering day buckets, monthRangeToDayBuckets) both report them; window= +// hour omits both fields entirely rather than over-counting from whole +// days outside the requested range. +// +// The hour and day r402 buckets are seeded with DIFFERENT values +// specifically so reading the wrong window is caught immediately, not +// masked by both windows happening to agree. +func TestAdminUsageModelsDetail_R402AndCacheExtras_WindowAware(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + + cfg := newAdminTestConfig() + ln := newBehavioralRedisServer(t) + cfg.Redis = &RedisConfig{Address: ln.Addr().String()} + cfg.Cache = CacheConfig{Enabled: true, TTL: "1m"} + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + id := "alpha/a-model-1" + gw.limiter.incrCounter(kindModel, id, metricReq, windowHour, fixedNow, 1, hourWindowTTL) + gw.limiter.incrCounter(kindModel, id, metricReq, windowDay, fixedNow, 1, dayWindowTTL) + gw.limiter.incrCounter(kindModel, id, metricReq, windowMonth, fixedNow, 1, monthWindowTTL) + + gw.limiter.incrCounter(kindModel, id, metricR402, windowHour, fixedNow, 7, hourWindowTTL) + gw.limiter.incrCounter(kindModel, id, metricR402, windowDay, fixedNow, 30, dayWindowTTL) + gw.limiter.incrCounter(kindModel, id, metricChit, windowDay, fixedNow, 4, dayWindowTTL) + + findRow := func(t *testing.T, resp adminUsageModelsResponse) *adminUsageModelEntryView { + t.Helper() + for i := range resp.Models { + if resp.Models[i].ID == id { + return &resp.Models[i] + } + } + t.Fatalf("%s missing from detail ranking: %+v", id, resp.Models) + return nil + } + doDetail := func(t *testing.T, window string) adminUsageModelsResponse { + t.Helper() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageModelsPath+"?metric=req&window="+window+"&detail=1", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("window=%s status = %d, want 200, body=%s", window, rec.Code, rec.Body.String()) + } + var got adminUsageModelsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("window=%s decode: %v", window, err) + } + return got + } + + hourRow := findRow(t, doDetail(t, "hour")) + if hourRow.R402 == nil || *hourRow.R402 != 7 { + t.Errorf("window=hour R402 = %v, want 7 (the hour bucket, never the day bucket's 30)", hourRow.R402) + } + if hourRow.CacheHits != nil || hourRow.CacheSavedMicroUSD != nil { + t.Errorf("window=hour cache extras = (%v, %v), want both nil (no exact hour-window answer)", hourRow.CacheHits, hourRow.CacheSavedMicroUSD) + } + + dayRow := findRow(t, doDetail(t, "day")) + if dayRow.R402 == nil || *dayRow.R402 != 30 { + t.Errorf("window=day R402 = %v, want 30", dayRow.R402) + } + if dayRow.CacheHits == nil || *dayRow.CacheHits != 4 { + t.Errorf("window=day CacheHits = %v, want 4", dayRow.CacheHits) + } + + monthRow := findRow(t, doDetail(t, "month")) + if monthRow.R402 == nil || *monthRow.R402 != 30 { + t.Errorf("window=month R402 = %v, want 30 (converted to the covering day buckets)", monthRow.R402) + } + if monthRow.CacheHits == nil || *monthRow.CacheHits != 4 { + t.Errorf("window=month CacheHits = %v, want 4 (converted to the covering day buckets)", monthRow.CacheHits) + } +} + // TestAdminUsageModels_SpanStoreDownIs503 is TestAdminUsageModels_StoreDownIs503's // own span-path counterpart: an unreachable store must answer 503 through // chunkedModelSpanTotals too, not just modelTotals' own span=1 path. @@ -2755,7 +2946,7 @@ func TestChunkedModelSpanTotals_SplitsIntoMultipleRoundTrips(t *testing.T) { l.nowFn = func() time.Time { return fixedNow } gw := &Gateway{limiter: l} - got, ok := gw.chunkedModelSpanTotals(ids, metricReq, windowDay, fixedNow, span) + got, ok := gw.chunkedModelSpanTotals(ids, metricReq, windowDay, fixedNow, span, 0) if !ok { t.Fatal("want ok=true") } @@ -2806,7 +2997,7 @@ func TestChunkedModelSpanTotalsMulti_SplitsIntoMultipleRoundTrips(t *testing.T) l.nowFn = func() time.Time { return fixedNow } gw := &Gateway{limiter: l} - got, ok := gw.chunkedModelSpanTotalsMulti(ids, metrics, windowDay, fixedNow, span) + got, ok := gw.chunkedModelSpanTotalsMulti(ids, metrics, windowDay, fixedNow, span, 0) if !ok { t.Fatal("want ok=true") } @@ -3048,6 +3239,63 @@ func TestAdminUsage_RejectionsPerDayField(t *testing.T) { } } +// TestAdminUsage_LastSeen pins GET /admin/api/usage's LastSeen field (Q6, +// DECISIONS): a scope with a seeded lastSeenKey (limits.go, WP-A) +// reports it back verbatim, unix seconds; a scope with none reports 0, +// which its own omitempty tag drops from the JSON entirely ("lastSeen 0 +// means never/unknown"). +func TestAdminUsage_LastSeen(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 9, 0, 0, 0, time.UTC) + seenAt := fixedNow.Add(-90 * time.Second).Unix() + + store := &countingMultiStore{values: map[string]int64{ + lastSeenKey("user", "alice"): seenAt, + // admin1 deliberately unseeded: no key at all. + }} + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsagePath, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminUsageResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + byName := map[string]adminUsageEntryView{} + for _, u := range got.Users { + byName[u.ID] = u + } + if byName["alice"].LastSeen != seenAt { + t.Errorf("alice.LastSeen = %d, want %d", byName["alice"].LastSeen, seenAt) + } + if byName["admin1"].LastSeen != 0 { + t.Errorf("admin1.LastSeen = %d, want 0 (never seen)", byName["admin1"].LastSeen) + } + if strings.Contains(rec.Body.String(), `"lastSeen":0`) { + t.Error(`response body contains "lastSeen":0, want omitted via omitempty`) + } +} + +// TestAdminUsage_ChunkScopesLoweredForLastSeenKey pins adminUsageChunkScopes +// == 160 (lowered from 200 when usageKeysPerScope grew to 10 for +// lastSeen) — a hand-picked constant a future edit could silently drift +// without a test catching it. +func TestAdminUsage_ChunkScopesLoweredForLastSeenKey(t *testing.T) { + t.Parallel() + if adminUsageChunkScopes != 160 { + t.Errorf("adminUsageChunkScopes = %d, want 160", adminUsageChunkScopes) + } + if adminUsageChunkScopes*usageKeysPerScope > 1600 { + t.Errorf("adminUsageChunkScopes*usageKeysPerScope = %d, want <= 1600", adminUsageChunkScopes*usageKeysPerScope) + } +} + // --- overview: replica/instance/warnings (F4/F9/F10, v0.3 dashboard task) --- // TestAdminOverview_Replica proves the overview response carries a diff --git a/admin_totals_test.go b/admin_totals_test.go new file mode 100644 index 0000000..544ae24 --- /dev/null +++ b/admin_totals_test.go @@ -0,0 +1,290 @@ +package traefikllmgateway + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// --- gate --- + +func TestAdminUsageTotals_GateMatrix(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + path := adminUsageTotalsPath + "?kind=user" + cases := []struct { + name string + apiKey string + want int + }{ + {"unauthenticated", "", http.StatusUnauthorized}, + {"non-admin", "sk-alice", http.StatusForbidden}, + {"admin", "sk-admin1", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, path, c.apiKey)) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +// --- kind matrix / param validation --- + +func TestAdminUsageTotals_KindMatrix(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + cases := []struct { + name string + qs string + want int + }{ + {"unknown kind", "?kind=bogus", http.StatusBadRequest}, + {"user", "?kind=user", http.StatusOK}, + {"group", "?kind=group", http.StatusOK}, + {"usermodel missing user", "?kind=usermodel", http.StatusBadRequest}, + {"usermodel disabled", "?kind=usermodel&user=alice", http.StatusNotFound}, + {"modeluser missing model", "?kind=modeluser", http.StatusBadRequest}, + {"modeluser disabled", "?kind=modeluser&model=alpha/a-model-1", http.StatusNotFound}, + {"targetcaller", "?kind=targetcaller", http.StatusOK}, + {"invalid window for usermodel", "?kind=targetcaller&window=hour", http.StatusBadRequest}, + {"invalid metrics", "?kind=user&metrics=bogus", http.StatusBadRequest}, + {"invalid limit", "?kind=user&limit=0", http.StatusBadRequest}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+c.qs, "sk-admin1")) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + }) + } +} + +// TestAdminUsageTotals_UserModelEnabled404sWhenOff pins that BOTH +// usermodel and modeluser 404 while admin.stats.userModel is off — the +// default (DECISIONS Q4) — and both succeed once it is on. +func TestAdminUsageTotals_UserModelEnabled404sWhenOff(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + for _, qs := range []string{"?kind=usermodel&user=alice", "?kind=modeluser&model=alpha/a-model-1"} { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+qs, "sk-admin1")) + if rec.Code != http.StatusNotFound { + t.Errorf("qs=%q: status = %d, want 404 (userModel stats off)", qs, rec.Code) + } + } + + cfg2 := newAdminTestConfig() + cfg2.Admin.Stats = &AdminStatsConfig{UserModel: true} + h2, _ := newAdminGatewayHandle(t, cfg2) + for _, qs := range []string{"?kind=usermodel&user=alice", "?kind=modeluser&model=alpha/a-model-1"} { + rec := httptest.NewRecorder() + h2.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+qs, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Errorf("qs=%q: status = %d, want 200 (userModel stats on), body=%s", qs, rec.Code, rec.Body.String()) + } + } +} + +// TestAdminUsageTotals_UsermodelTwoPhaseRead seeds one candidate model +// with nonzero req in the cheap-phase covering window, and one with none +// at all, then confirms only the nonzero candidate's actual umodel +// counter is read and returned — the zero-req model never reaches the +// second (per-user) phase at all, pinning the "usermodel candidates via +// cheap phase... then umodel keys for nonzero candidates" optimization +// (plan §1.3(v)). +func TestAdminUsageTotals_UsermodelTwoPhaseRead(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + cfg := newAdminTestConfig() + cfg.Admin.Stats = &AdminStatsConfig{UserModel: true} + // Single-model catalog per provider keeps the cheap-phase scope small + // and the assertion unambiguous. + cfg.Providers["alpha"].Models = []string{"a-model-1"} + cfg.Providers["zeta"].Models = []string{"z-model"} + + h, gw := newAdminGatewayHandle(t, cfg) + + monthAnchor := historyStepBack(fixedNow, windowMonth, 0) + umodelID := userModelScopeID("alice", "alpha/a-model-1") + + store := &countingMultiStore{values: map[string]int64{ + // Cheap phase: alpha/a-model-1 has usage this month, zeta/z-model + // does not (absent key == 0 already, nothing to seed). + windowKey(kindModel, "alpha/a-model-1", metricReq, windowMonth, monthAnchor): 5, + // Second phase: alice's own umodel counters for that one candidate. + windowKey(kindUserModel, umodelID, metricReq, windowDay, fixedNow): 3, + windowKey(kindUserModel, umodelID, metricCost, windowDay, fixedNow): 9000, + windowKey(kindUserModel, umodelID, metricTokIn, windowDay, fixedNow): 0, + windowKey(kindUserModel, umodelID, metricTokOut, windowDay, fixedNow): 0, + }} + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+"?kind=usermodel&user=alice&window=day", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminTotalsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Rows) != 1 { + t.Fatalf("Rows = %+v, want exactly 1 row (alpha/a-model-1)", got.Rows) + } + row := got.Rows[0] + if row.ID != "alpha/a-model-1" { + t.Errorf("Rows[0].ID = %q, want alpha/a-model-1", row.ID) + } + if row.Values[metricReq] != 3 || row.Values[metricCost] != 9000 { + t.Errorf("Rows[0].Values = %+v, want req=3 cost=9000", row.Values) + } +} + +// TestAdminUsageTotals_DropsAllZeroRows pins "Drop all-zero rows" (plan +// §1.3(v)) for the user kind. +func TestAdminUsageTotals_DropsAllZeroRows(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + + store := &countingMultiStore{values: map[string]int64{ + windowKey("user", "alice", metricReq, windowDay, fixedNow): 4, + // admin1's own row: every requested metric stays 0 (unseeded). + }} + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+"?kind=user&window=day", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminTotalsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + for _, row := range got.Rows { + if row.ID == "admin1" { + t.Errorf("all-zero row for admin1 present, want dropped: %+v", row) + } + } +} + +// TestAdminUsageTotals_StoreDownIs503 mirrors the series/models endpoints' +// own store-failure contract. +func TestAdminUsageTotals_StoreDownIs503(t *testing.T) { + t.Parallel() + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + gw.limiter = newLimiter(alwaysErrStore{}, false) + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+"?kind=user", "sk-admin1")) + if rec.Code != http.StatusServiceUnavailable { + t.Errorf("status = %d, want 503, body=%s", rec.Code, rec.Body.String()) + } +} + +// TestAdminUsageTotals_TargetCallerRowID pins the targetcaller row id +// shape ("mcp/{target}/{caller}" — plan's own "mcp/fetch/alice" example). +func TestAdminUsageTotals_TargetCallerRowID(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + cfg := newAdminTestConfig() + cfg.MCPServers = map[string]*TargetConfig{"fetch": {URL: "http://fetch.invalid"}} + h, gw := newAdminGatewayHandle(t, cfg) + + id := targetCallerScopeID("mcp", "fetch", "alice") + store := &countingMultiStore{values: map[string]int64{ + windowKey(kindTargetCaller, id, metricReq, windowDay, fixedNow): 2, + }} + gw.limiter = newLimiter(store, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageTotalsPath+"?kind=targetcaller&window=day", "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got adminTotalsResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + var found bool + for _, row := range got.Rows { + if row.ID == "mcp/fetch/alice" { + found = true + if row.Values[metricReq] != 2 { + t.Errorf("row values = %+v, want req=2", row.Values) + } + } + } + if !found { + t.Errorf("Rows = %+v, want a row id 'mcp/fetch/alice'", got.Rows) + } +} + +// TestReadSpanTotals_ChunksAtUsageModelsChunkKeys is P6/P12 (admin +// dashboard redesign verify round): readSpanTotals — the reader behind +// GET /admin/api/usage/totals' kind=user/group/usermodel/targetcaller +// rows and GET /admin/api/performance's own aggregate (series=0) mode — +// used to build ONE unbounded storeGetMulti pipeline covering its whole +// ids*metrics*span key set. It now delegates to the shared, chunked +// spanTotalsMulti reader (limits.go) that also backs admin.go's +// chunkedModelSpanTotals/Multi (TestChunkedModelSpanTotals_ +// SplitsIntoMultipleRoundTrips, admin_test.go, pins that side). +func TestReadSpanTotals_ChunksAtUsageModelsChunkKeys(t *testing.T) { + t.Parallel() + fixedNow := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + const n = 200 + const span = 48 + ids := make([]string, n) + for i := range ids { + ids[i] = fmt.Sprintf("u%03d", i) + } + store := &countingMultiStore{values: map[string]int64{}} + l := newLimiter(store, true) + l.nowFn = func() time.Time { return fixedNow } + gw := &Gateway{limiter: l} + + metrics := []string{metricProvAttempt, metricProvFail} + got, ok := gw.readSpanTotals("user", ids, metrics, windowHour, fixedNow, span, 0) + if !ok { + t.Fatal("want ok=true") + } + if len(got) != n { + t.Fatalf("len(got) = %d, want %d", len(got), n) + } + + // wantCalls mirrors spanTotalsMulti's own chunk-by-ID-count math + // (limits.go): perChunk = usageModelsChunkKeys / (span*len(metrics)), + // truncated, so a chunk can carry fewer than usageModelsChunkKeys keys + // when that division does not land evenly — chunking by whole ids, + // never splitting one id's own key range across two round trips, so + // the per-chunk key count is not always maximally packed. + perChunk := usageModelsChunkKeys / (len(metrics) * span) + wantCalls := (n + perChunk - 1) / perChunk + if store.getMultiCalls != wantCalls { + t.Errorf("getMultiCalls = %d, want %d (%d ids, %d ids/chunk)", store.getMultiCalls, wantCalls, n, perChunk) + } + if store.getMultiCalls <= 1 { + t.Error("want more than 1 round trip — otherwise this test proves nothing about chunking") + } +} diff --git a/auth.go b/auth.go index d6749a2..0d12b55 100644 --- a/auth.go +++ b/auth.go @@ -1251,38 +1251,43 @@ func presentedKey(r *http.Request) (string, bool) { // construction" rule this file's own doc comment already states for // byDigest itself. type userSummary struct { - limits *LimitsConfig - name string - groupName string - // groups is the user's full, de-duplicated member group list - // (user.groupNames) — groupName above keeps only the first, for - // back-compat display (admin.go's adminUsageEntryView.GroupName). - groups []string + limits *LimitsConfig + name string + groupName string + source string + groups []string + personalProviders []string + personalModels []string + admin bool } // groupSummary mirrors userSummary for one configured group, plus its // current member count (how many active users, inline or file-sourced, // currently reference it) and its configured access lists — providers, -// models, mcpServers, agents — carried through exactly as group.allowsX -// itself reads them (group-access-display task): nil/empty means every -// provider/model/MCP server/agent is allowed (matchesGlob's own contract, -// auth.go), never expanded to the full catalog here — the admin dashboard -// echoes the configured glob list, not a resolved membership set. +// models, mcpServers, agents, passthroughPaths — carried through exactly +// as group.allowsX itself reads them (group-access-display task; GET +// /admin/api/consumers, admin_catalog.go, added passthroughPaths): +// nil/empty means every provider/model/MCP server/agent/path is allowed +// (matchesGlob's own contract, auth.go), never expanded to the full +// catalog here — the admin dashboard echoes the configured glob list, +// not a resolved membership set. // -// snapshot (below) clones all four slices rather than aliasing group's own -// — group.providers/models/mcpServers/agents are the live authorization -// data every allowsX call reads on the request path; groupSummary is a -// read-only, dashboard-facing COPY, so a future caller that sorts or -// otherwise mutates a groupSummary's list in place can never reorder or -// corrupt the slice authorization itself still relies on. +// snapshot (below) clones all five slices rather than aliasing group's own +// — group.providers/models/mcpServers/agents/passthroughPaths are the +// live authorization data every allowsX call reads on the request path; +// groupSummary is a read-only, dashboard-facing COPY, so a future caller +// that sorts or otherwise mutates a groupSummary's list in place can +// never reorder or corrupt the slice authorization itself still relies +// on. type groupSummary struct { - limits *LimitsConfig - name string - providers []string - models []string - mcpServers []string - agents []string - memberCount int + limits *LimitsConfig + name string + providers []string + models []string + mcpServers []string + agents []string + passthroughPaths []string + memberCount int } // snapshot returns a sorted, redaction-safe listing of every currently @@ -1294,12 +1299,25 @@ func (a *authStore) snapshot() ([]userSummary, []groupSummary) { a.mu.RLock() users := make([]userSummary, 0, len(a.byDigest)) memberCounts := make(map[string]int, len(a.groups)) - for _, entry := range a.byDigest { + for digest, entry := range a.byDigest { + source := "file" + if a.inline[digest] == entry { + source = "inline" + } + var personalProviders, personalModels []string + if entry.group.personalGrant != nil { + personalProviders = cloneStringSlice(entry.group.personalGrant.providers) + personalModels = cloneStringSlice(entry.group.personalGrant.models) + } users = append(users, userSummary{ - limits: entry.user.limits, - name: entry.user.name, - groupName: entry.user.groupName, - groups: cloneStringSlice(entry.user.groupNames), + limits: entry.user.limits, + name: entry.user.name, + groupName: entry.user.groupName, + groups: cloneStringSlice(entry.user.groupNames), + personalProviders: personalProviders, + personalModels: personalModels, + source: source, + admin: entry.user.admin, }) // A multi-group user counts once in EACH member group's own // memberCount (UserConfig.Groups' own doc comment, llmgateway.go) @@ -1317,13 +1335,14 @@ func (a *authStore) snapshot() ([]userSummary, []groupSummary) { groups := make([]groupSummary, 0, len(a.groups)) for name, grp := range a.groups { groups = append(groups, groupSummary{ - limits: grp.limits, - name: name, - providers: cloneStringSlice(grp.providers), - models: cloneStringSlice(grp.models), - mcpServers: cloneStringSlice(grp.mcpServers), - agents: cloneStringSlice(grp.agents), - memberCount: memberCounts[name], + limits: grp.limits, + name: name, + providers: cloneStringSlice(grp.providers), + models: cloneStringSlice(grp.models), + mcpServers: cloneStringSlice(grp.mcpServers), + agents: cloneStringSlice(grp.agents), + passthroughPaths: cloneStringSlice(grp.passthroughPaths), + memberCount: memberCounts[name], }) } sort.Slice(groups, func(i, j int) bool { return groups[i].name < groups[j].name }) diff --git a/auth_test.go b/auth_test.go index b01b44a..381fe18 100644 --- a/auth_test.go +++ b/auth_test.go @@ -430,6 +430,118 @@ func TestAuthStore_Snapshot_MultiGroupUser_ListsAllGroupsAndCountsEachMember(t * } } +// TestAuthStore_Snapshot_SourceInlineVsFile (admin dashboard redesign, +// WP-B) pins userSummary.source: an inline user reports "inline", and a +// file-sourced user — even one that overrides an inline user of the same +// name (replaceFileUsers' own doc comment) — reports "file". +func TestAuthStore_Snapshot_SourceInlineVsFile(t *testing.T) { + cfg := &Config{ + Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}, + Groups: map[string]*GroupConfig{"eng": {}}, + Users: &UsersConfig{Inline: []*UserConfig{ + {Name: "inline-only", Group: "eng", APIKey: "sk-inline"}, + {Name: "overridden", Group: "eng", APIKey: "sk-old"}, + }}, + } + a, err := newAuthStore(cfg) + if err != nil { + t.Fatal(err) + } + if err := a.replaceFileUsers([]*UserConfig{ + {Name: "overridden", Group: "eng", APIKey: "sk-new"}, + {Name: "file-only", Group: "eng", APIKey: "sk-file"}, + }); err != nil { + t.Fatal(err) + } + + users, _ := a.snapshot() + got := map[string]string{} + for _, u := range users { + got[u.name] = u.source + } + want := map[string]string{"inline-only": "inline", "overridden": "file", "file-only": "file"} + for name, wantSource := range want { + if got[name] != wantSource { + t.Errorf("source[%q] = %q, want %q", name, got[name], wantSource) + } + } +} + +// TestAuthStore_Snapshot_AdminAndPersonalGrant (admin dashboard redesign, +// WP-B) pins userSummary.admin and personalProviders/personalModels: a +// user with no personal grant reports both nil; a user with one reports +// exactly its own Providers/Models, not the member group's resolved +// access. +func TestAuthStore_Snapshot_AdminAndPersonalGrant(t *testing.T) { + cfg := &Config{ + Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}, + Groups: map[string]*GroupConfig{"eng": {Providers: []string{"anthropic"}}}, + Users: &UsersConfig{Inline: []*UserConfig{ + {Name: "plain", Group: "eng", APIKey: "sk-plain"}, + {Name: "admin-user", Group: "eng", APIKey: "sk-admin", Admin: true}, + {Name: "grantee", Group: "eng", APIKey: "sk-grant", Providers: []string{"openai"}, Models: []string{"gpt-5"}}, + }}, + } + a, err := newAuthStore(cfg) + if err != nil { + t.Fatal(err) + } + users, _ := a.snapshot() + byName := map[string]userSummary{} + for _, u := range users { + byName[u.name] = u + } + + if byName["plain"].admin { + t.Error(`users["plain"].admin = true, want false`) + } + if !byName["admin-user"].admin { + t.Error(`users["admin-user"].admin = false, want true`) + } + if byName["plain"].personalProviders != nil || byName["plain"].personalModels != nil { + t.Errorf("plain user's personal grant = (%v, %v), want (nil, nil)", byName["plain"].personalProviders, byName["plain"].personalModels) + } + g := byName["grantee"] + if len(g.personalProviders) != 1 || g.personalProviders[0] != "openai" { + t.Errorf("grantee personalProviders = %v, want [openai]", g.personalProviders) + } + if len(g.personalModels) != 1 || g.personalModels[0] != "gpt-5" { + t.Errorf("grantee personalModels = %v, want [gpt-5]", g.personalModels) + } +} + +// TestAuthStore_Snapshot_GroupPassthroughPaths (admin dashboard redesign, +// WP-B) pins groupSummary.passthroughPaths: carried through unchanged +// from GroupConfig.PassthroughPaths, cloned (not aliased) like the other +// access-list fields. +func TestAuthStore_Snapshot_GroupPassthroughPaths(t *testing.T) { + cfg := &Config{ + Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}, + Groups: map[string]*GroupConfig{ + "eng": {PassthroughPaths: []string{"v1/chat/completions"}}, + "unrestrict": {}, + }, + Users: &UsersConfig{Inline: []*UserConfig{ + {Name: "a", Group: "eng", APIKey: "sk-a"}, + }}, + } + a, err := newAuthStore(cfg) + if err != nil { + t.Fatal(err) + } + _, groups := a.snapshot() + byName := map[string]groupSummary{} + for _, g := range groups { + byName[g.name] = g + } + if len(byName["eng"].passthroughPaths) != 1 || byName["eng"].passthroughPaths[0] != "v1/chat/completions" { + t.Errorf(`groups["eng"].passthroughPaths = %v, want ["v1/chat/completions"]`, byName["eng"].passthroughPaths) + } + if byName["unrestrict"].passthroughPaths != nil { + t.Errorf(`groups["unrestrict"].passthroughPaths = %v, want nil`, byName["unrestrict"].passthroughPaths) + } +} + func TestNewAuthStore_DuplicateAPIKey_ReturnsError(t *testing.T) { cfg := &Config{ Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}}, diff --git a/cache.go b/cache.go index 2066f3b..8100d84 100644 --- a/cache.go +++ b/cache.go @@ -349,6 +349,29 @@ func (c *responseCache) store(key string, status int, contentType string, body [ } } +// cachedUsage extracts the prompt/completion token counts a cached +// response body's own top-level "usage" object reports — the OpenAI- +// compatible shape every non-stream JSON response this gateway's adapters +// produce already carries — used only by a response-cache HIT +// (routes_unified.go's own call site, admin.stats + cache both enabled) +// to attribute the avoided cost (recordCacheHit, limits.go) against the +// same numbers a MISS would have billed. A body with no "usage" object, +// or one that fails to unmarshal, reports a zero usage{}: a hit still +// counts (chit), it simply has nothing to value (csave stays 0 and is +// skipped, mirroring account's own "skip a zero direction" rule). +func cachedUsage(body []byte) usage { + var parsed struct { + Usage *struct { + Prompt int64 `json:"prompt_tokens"` + Completion int64 `json:"completion_tokens"` + } `json:"usage"` + } + if err := json.Unmarshal(body, &parsed); err != nil || parsed.Usage == nil { + return usage{} + } + return usage{prompt: parsed.Usage.Prompt, completion: parsed.Usage.Completion} +} + // cacheEndpointChat and cacheEndpointEmbeddings are cacheKey's endpoint // argument, one per cacheable route (routes_unified.go's handleChat/ // handleEmbeddings) — keeping /v1/chat/completions and /v1/embeddings diff --git a/cache_test.go b/cache_test.go index 1d7ede4..dca4062 100644 --- a/cache_test.go +++ b/cache_test.go @@ -820,3 +820,42 @@ func TestResponseCache_Store_EffectiveTTLDiffersPerGroup_SETCarriesGroupTTL(t *t c.store("k-override", 200, "application/json", []byte(`{"a":1}`), effectiveTTL(c, overrideGrp)) c.store("k-inherit", 200, "application/json", []byte(`{"b":2}`), effectiveTTL(c, inheritGrp)) } + +// --- admin-redesign WP-A step 6: cachedUsage --- + +// TestCachedUsage_ParsesOpenAIShapedUsageObject proves cachedUsage extracts +// prompt_tokens/completion_tokens from a cached response body's top-level +// "usage" object — recordCacheHit's own source for a cache HIT's avoided +// cost (routes_unified.go). +func TestCachedUsage_ParsesOpenAIShapedUsageObject(t *testing.T) { + body := []byte(`{"id":"chatcmpl-1","choices":[],"usage":{"prompt_tokens":40,"completion_tokens":10}}`) + got := cachedUsage(body) + if got.prompt != 40 || got.completion != 10 { + t.Errorf("cachedUsage = %+v, want prompt=40 completion=10", got) + } +} + +// TestCachedUsage_MissingOrMalformed_ReturnsZeroUsage proves a body with +// no "usage" object, or one that fails to unmarshal at all, reports a +// zero usage{} — a cache hit must still count (chit), it simply has +// nothing to value (csave skipped, matching account's own "skip a zero +// direction" rule). +func TestCachedUsage_MissingOrMalformed_ReturnsZeroUsage(t *testing.T) { + cases := []struct { + name string + body []byte + }{ + {name: "no usage field", body: []byte(`{"id":"chatcmpl-1","choices":[]}`)}, + {name: "usage is null", body: []byte(`{"usage":null}`)}, + {name: "malformed JSON", body: []byte(`not json`)}, + {name: "empty body", body: []byte(``)}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := cachedUsage(c.body) + if got != (usage{}) { + t.Errorf("cachedUsage(%q) = %+v, want the zero value", c.body, got) + } + }) + } +} diff --git a/events.go b/events.go index 71142ba..ac96f27 100644 --- a/events.go +++ b/events.go @@ -452,6 +452,13 @@ func (g *Gateway) recordUnpricedRefusalEvent(scopes []limitScope, model, provide } applyScopeAttribution(&ev, scopes) g.recordEvent(ev) + // r402 (always-on-with-admin, DECISIONS) — the one chokepoint both + // routes_unified.go's and routes_passthrough.go's identical 402 guard + // already share, so wiring the counter here covers both call sites + // without touching either route file again. Fire-and-forget + // (recordUnpriced402's own countAsync/l.spawn); no-op when admin + // stats are off. + g.limiter.recordUnpriced402(model) } // recordProxyEvent records an upstream event for a proxyUpstream result diff --git a/limits.go b/limits.go index e3a3b20..3749854 100644 --- a/limits.go +++ b/limits.go @@ -6,6 +6,7 @@ import ( "fmt" "math" "net/http" + "strconv" "strings" "sync" "time" @@ -65,6 +66,80 @@ const ( metricRej = "rej" ) +// New counter-family metric names (admin-redesign WP-A): landed together, +// early, so WP-B's read-side endpoints (admin.go, stats_read.go) can code +// against the agreed names while WP-A finishes wiring their writers. +// metricProvTimeout is the provider-level "did not answer within budget" +// counter (recordProviderAttempt, below) — distinct from metricProvFail +// (isTransient's broader classification): every timeout is a fail, but not +// every fail is a timeout. metricFover is the failover counter +// (accountWith's accountExtras.failoverFrom): +1 against the FROM +// provider every time a request moves to its next candidate. metricR402 +// is the "refused: unpriced model under a cost budget" counter +// (recordUnpriced402). metricChit/metricCsave/metricCmiss are the response +// cache's hit/avoided-cost/miss counters (recordCacheHit, accountWith's +// accountExtras.cacheMiss) — csave is micro-USD, the same unit metricCost +// already uses, not a request count. +const ( + metricProvTimeout = "timeout" + metricFover = "fover" + metricR402 = "r402" + metricChit = "chit" + metricCsave = "csave" + metricCmiss = "cmiss" +) + +// kindUserModel and kindTargetCaller are two more limitScope.kind (and +// admin-API scope-kind) strings this task adds, alongside kindProvider/ +// kindProviderModel/kindModel above: kindUserModel is the opt-in per- +// (user, model) breakdown (userModelScopeID, below; admin.stats.userModel); +// kindTargetCaller is the per-(target, caller) breakdown countTargetRequestsBy +// writes (targetCallerScopeID, below). Both embed kind as their counter +// key's own leading segment, the same collision-safety every other kind +// constant in this file already relies on. +const ( + kindUserModel = "umodel" + kindTargetCaller = "tcaller" +) + +// userModelScopeID builds kindUserModel's id for (user, canonical): the +// user name's own byte length (decimal ASCII), ":", then the user name +// immediately followed by canonical with NO separator between them. The +// length prefix is what makes this unambiguous to split back apart later +// (WP-B's usage/totals?kind=usermodel reader): a user name may itself +// contain any character a configured username allows, including one that +// could otherwise collide with a plain fixed separator, but canonical +// always starts exactly len(user) bytes after the first ":". +func userModelScopeID(user, canonical string) string { + return strconv.Itoa(len(user)) + ":" + user + canonical +} + +// targetCallerScopeID builds kindTargetCaller's id for (targetKind, +// target, caller): "{mcp|agent}/{target}/{caller}" — targetKind is +// targetScopeKind's own output ("mcp" or "agent", mcp_a2a.go), so this +// reuses the identical vocabulary GET /admin/api/targets already exposes +// rather than minting a third one. +func targetCallerScopeID(targetKind, target, caller string) string { + return targetKind + "/" + target + "/" + caller +} + +// latencyDurationMetric and latencyTTFBMetric name the per-bucket latency +// counters accountWith writes (opt-in, admin.stats.latency): idx is +// latencyBucketIndex's own return value (metrics.go) — 0..len( +// latencyBucketBounds)-1 for a real bucket, len(latencyBucketBounds) itself +// for the overflow bucket — so "ld00".."ld13"/"lt00".."lt13" for the 13 +// configured bounds plus one overflow bucket each, matching plan §1.2's +// "ld00..ld13"/"lt00..lt13" vocabulary exactly. %02d, not a bare int, so +// every bucket's metric name sorts and reads consistently regardless of +// how many bounds latencyBucketBounds ever grows to hold (up to 100). +func latencyDurationMetric(idx int) string { + return fmt.Sprintf("ld%02d", idx) +} + +func latencyTTFBMetric(idx int) string { + return fmt.Sprintf("lt%02d", idx) +} + // TTLs applied to counter keys. They exceed their window's natural length // so a key stays readable for the whole window it belongs to; the bucket // embedded in the key (see windowKey) already makes a rolled-over window @@ -189,6 +264,17 @@ type counterIncr struct { // this field entirely — ttl is applied in full there; the ceiling and // this floor only ever matter for the in-process fallback. enforceTTL time.Duration + // absolute marks this entry as a plain SET, not an INCRBY: delta is + // the exact value to write (e.g. a unix-second timestamp), not an + // amount to add. Added for the last-seen counter family (Q6, + // DECISIONS): a last-seen value is "when did we last see this scope", + // which an increment can never express. redisStore.incrMulti/ + // incrAndGetMulti build a single "SET key delta EX secs" command for + // an absolute entry instead of INCRBY(+EXPIRE) — see their own doc + // comments — and memoryStore.applySet mirrors that: unconditionally + // overwrite, never add. false (the zero value) is every existing + // entry's own behavior, unchanged. + absolute bool } // counterStore is the storage backend the limiter uses for atomic windowed @@ -296,35 +382,6 @@ const sweepEvery = 30 * time.Second // "fallback retention" note. const memoryStoreMaxTTL = 48 * time.Hour -// enforceTTLFor returns the minimum TTL window's own counter must stay -// alive for to enforce correctly, independent of how long history -// retention (hourWindowTTL/dayWindowTTL/monthWindowTTL) asks the same key -// to live for charting. These are windowLength + margin, and not -// coincidentally match dayWindowTTL/monthWindowTTL's ORIGINAL, -// pre-history-retention-bump values (25h/32d, v0.2 data-layer task) — -// that bump only ever existed to serve the usage-history API's charts; -// enforcement itself only ever needed a key to outlive its own window's -// single rollover. Called for every checkAndCount/account/ -// countTargetRequests/recordProviderAttempt entry (directly, or via -// enforceTTLsFor's own equivalent four-window fan-out); a window not -// among the four handled here is a -// programming error, mirroring windowKey/windowEnd/bucketFor's own panic -// convention. -func enforceTTLFor(window string) time.Duration { - switch window { - case windowMin: - return enforceTTLMinWindow - case windowHour: - return enforceTTLHourWindow - case windowDay: - return enforceTTLDayWindow - case windowMonth: - return enforceTTLMonthWindow - default: - panic(fmt.Sprintf("llmgateway: enforceTTLFor: unknown window %q", window)) - } -} - // clampTTL bounds ttl to at most memoryStoreMaxTTL, but never below // enforceTTL — see both consts'/enforceTTLFor's own doc comments for why // the floor must always win a conflict with the ceiling. enforceTTL=0 @@ -396,6 +453,26 @@ func (m *memoryStore) applyIncr(key string, n int64, ttl time.Duration) int64 { return e.value } +// applySet sets key's value to v and its expiry to now+ttl, unconditionally +// — creating the entry if absent, overwriting it if present — memoryStore's +// counterpart to Redis's SET key v EX secs (an absolute counterIncr entry's +// own command, redisStore.incrMulti). Unlike applyIncr, this never adds to +// an existing value and never treats "already expired" specially: every +// call simply replaces whatever was there, exactly like a real SET does. +func (m *memoryStore) applySet(key string, v int64, ttl time.Duration) int64 { + now := m.now() + m.mu.Lock() + defer m.mu.Unlock() + + if now.Sub(m.lastSweep) >= sweepEvery { + m.sweepLocked(now) + m.lastSweep = now + } + + m.data[key] = &memoryEntry{expiry: now.Add(ttl), value: v} + return v +} + // get implements counterStore. func (m *memoryStore) get(key string) (int64, error) { now := m.now() @@ -434,6 +511,10 @@ func (m *memoryStore) getMulti(keys []string) ([]int64, error) { func (m *memoryStore) incrMulti(entries []counterIncr) ([]int64, error) { out := make([]int64, len(entries)) for i, e := range entries { + if e.absolute { + out[i] = m.applySet(e.key, e.delta, clampTTL(e.ttl, e.enforceTTL)) + continue + } out[i] = m.applyIncr(e.key, e.delta, clampTTL(e.ttl, e.enforceTTL)) } return out, nil @@ -528,14 +609,156 @@ type limitViolation struct { storeDown bool } +// lastSeenGateThrottle is the minimum real time between two last-seen SET +// writes THIS replica issues for the identical (kind, id) scope (Q6, +// DECISIONS: "last-seen via absolute SET entries in the admission +// pipeline, throttled 60s/replica/scope"). An absolute SET is idempotent +// and safe to send on every admitted request, but doing so would add a +// synchronous store round-trip cost proportional to request volume for a +// value nothing needs updated more often than this. +const lastSeenGateThrottle = 60 * time.Second + +// lastSeenGateMapCapBase is lastSeenGate.capacity's starting value, and +// the floor it never shrinks below — the same generation-rotation shape +// rejectionCounter/authFailureTracker already use, and for the identical +// reason (rejectionCounterMapCap's own doc comment): a hot-reloaded +// user/group set can churn names over a long process lifetime, and +// nothing here should grow this map without bound for a renamed or +// deleted scope. A scope displaced by rotation simply writes its next +// last-seen SET a little early — never a correctness bug, only an extra +// write, the same trade-off rejectionCounter's own doc comment already +// accepts. +const lastSeenGateMapCapBase = 4096 + +// lastSeenGateMapCapMax is the hard ceiling lastSeenGate.capacity's own +// adaptive growth (below) never exceeds — bounding this replica's total +// last-seen throttle memory at, worst case, two generations of +// lastSeenGateMapCapMax entries (current + previous) regardless of how +// many distinct scopes this process ever admits. N5 (verify-redesign- +// final.md): a fixed 4,096 cap throttled correctly up to ~2x itself +// (~8,192 live scopes, current+previous both full), but a live scope +// count ABOVE that rotated current before lastSeenGateThrottle could +// ever elapse for most of it, discarding still-fresh writes on every +// rotation and degrading to one write per admission — the throttle it +// exists to provide. Past this ceiling the gate degrades gracefully back +// to the same fixed-size rotate/discard behaviour a below-ceiling gate +// always had, rather than growing without bound. +const lastSeenGateMapCapMax = 65536 + +// lastSeenGate throttles checkAndCount's own last-seen SET writes +// (lastSeenEntries, below) per (kind, id) scope, per replica: due reports +// true (and records now) only when this replica has never written one for +// that scope, or its last write was more than lastSeenGateThrottle ago. +// Guarded by mu since checkAndCount runs on every request's own goroutine +// concurrently. +// +// capacity is the CURRENT rotation threshold (starts at +// lastSeenGateMapCapBase, only ever grows, up to lastSeenGateMapCapMax — +// see due's own doc comment for when and why it grows) rather than the +// fixed lastSeenGateMapCap constant an earlier version used. +type lastSeenGate struct { + current map[string]int64 // kind+"\x00"+id -> unix seconds of this replica's last write + previous map[string]int64 + lastRotate time.Time // zero until the first rotation; read/written under mu, same as the two maps + capacity int + mu sync.Mutex +} + +// due reports whether this replica should write a fresh last-seen SET for +// (kind, id) at now, recording now as that scope's new last-write time +// when it does. +// +// Adaptive capacity (N5 fix, verify-redesign-final.md): when current +// fills past g.capacity, due does not always rotate. It rotates (moving +// current to previous, starting a fresh current) only on that scope's +// FIRST overflow, or once at least lastSeenGateThrottle has passed since +// the previous rotation — the same signal a healthy, below-capacity gate +// already relies on (a population that fits comfortably takes at least +// that long to refill current). When current instead refills WITHIN one +// throttle window of the last rotation, that is direct evidence the live +// scope population exceeds 2x the current capacity: rotating now would +// immediately discard `previous`'s still-fresh writes and start evicting +// the very entries that just proved the map too small. Growing capacity +// instead (doubled, capped at lastSeenGateMapCapMax) lets current keep +// accumulating past the old threshold without losing anything already +// recorded, so a population that stabilizes below the ceiling ends up +// fully covered by current+previous, and the throttle holds for it the +// same way it always did below ~2x lastSeenGateMapCapBase. +func (g *lastSeenGate) due(kind, id string, now time.Time) bool { + key := kind + "\x00" + id + nowUnix := now.Unix() + g.mu.Lock() + defer g.mu.Unlock() + if g.current == nil { + g.current = make(map[string]int64) + g.capacity = lastSeenGateMapCapBase + } + if last, ok := g.current[key]; ok { + if nowUnix-last < int64(lastSeenGateThrottle/time.Second) { + return false + } + } else if last, ok := g.previous[key]; ok { + // P8 fix (admin dashboard redesign verify round): a scope + // written before the last rotation must still throttle against + // ITS OWN last write instead of being treated as brand new just + // because current rotated out from under it — previous existed + // for exactly this lookup, but nothing ever consulted it before + // this fix, so every scope's throttle silently reset on its + // first request after any rotation (i.e. constantly, once the + // map holds more than g.capacity active scopes). Copy the found + // timestamp forward into current so the NEXT lookup for this key + // hits the fast path above; previous is left otherwise untouched + // (it keeps answering for every other not-yet-migrated key until + // the next rotation replaces it). + if nowUnix-last < int64(lastSeenGateThrottle/time.Second) { + g.current[key] = last + return false + } + } else if len(g.current) >= g.capacity { + if g.capacity < lastSeenGateMapCapMax && !g.lastRotate.IsZero() && now.Sub(g.lastRotate) < lastSeenGateThrottle { + g.capacity *= 2 + if g.capacity > lastSeenGateMapCapMax { + g.capacity = lastSeenGateMapCapMax + } + } else { + g.previous = g.current + g.current = make(map[string]int64, g.capacity) + g.lastRotate = now + } + } + g.current[key] = nowUnix + return true +} + +// lastSeenKey builds the counterStore key an absolute last-seen SET entry +// writes to for (kind, id): "llmgw:seen:{kind}:{id}" — a fixed key with no +// window-bucket component (unlike windowKey/windowKeyForBucket): a +// last-seen timestamp has no natural rollover, so there is exactly one key +// per scope, never one per bucket. Exposed for WP-B's own GET +// /admin/api/usage reader (usageWindowKeys, below, already appends this +// alongside every other per-scope key) to build the identical key. +func lastSeenKey(kind, id string) string { + return "llmgw:seen:" + kind + ":" + id +} + // limiter enforces per-minute/day/month request, token, and cost limits // using fixed windows keyed by windowKey. type limiter struct { - lastLogAt time.Time // guarded by logMu; last time a store error was logged - lastStoreFailure time.Time // guarded by logMu; zero means the store-down latch is not open (see storeLatched) - store counterStore // configured backend; nil means always use fallback (see newLimiter) - fallback *memoryStore // in-process counter store, always available - nowFn func() time.Time // injected for tests; defaults to time.Now + // fieldalignment (govet): pointer-shaped fields first, grouped ahead + // of the two time.Time fields and every scalar below — same convention + // providerState/adminProviderView already document (registry.go/ + // admin.go). + lastStoreFailure time.Time // guarded by logMu; zero means the store-down latch is not open (see storeLatched) + lastLogAt time.Time // guarded by logMu; last time a store error was logged + store counterStore // configured backend; nil means always use fallback (see newLimiter) + // lastSeen throttles checkAndCount's own last-seen SET writes (Q6, + // DECISIONS) — always constructed by newLimiter (never nil), so every + // limiter built either directly or via newConfiguredLimiter can call + // lastSeenEntries safely regardless of whether admin stats end up + // enabled. + lastSeen *lastSeenGate + fallback *memoryStore // in-process counter store, always available + nowFn func() time.Time // injected for tests; defaults to time.Now // logf is a bound method value (g.errorf), injectable for tests; defaults // to a no-op. Its own call site (logStoreError) passes exactly one // variadic argument — never extend that to two or more without first @@ -583,13 +806,6 @@ type limiter struct { // semaphore would let one test's write volume starve an unrelated // test's — see newLimiter's own construction of it. spawnTokens chan struct{} - // lastErrMsg is the message of the most recent store operation - // failure, guarded by logMu alongside lastStoreFailure. It is never - // cleared on a later success — "last store error" for the admin - // dashboard (spec §4, v0.2) means exactly that, a persisting fact, - // not "is the store currently failing" (storeLatched already answers - // that question for the enforcement path). - lastErrMsg string // rejections tracks rate/budget-limit-violation counts per scope // (metrics.go's llmgateway_rate_limit_rejections_total). It is a // plain in-process, per-process-lifetime counter guarded by its own @@ -615,8 +831,35 @@ type limiter struct { // the network round trip that already failed to produce this // violation in the first place, never an ADDITIONAL one. rejections rejectionCounter + // lastErrMsg is the message of the most recent store operation + // failure, guarded by logMu alongside lastStoreFailure. It is never + // cleared on a later success — "last store error" for the admin + // dashboard (spec §4, v0.2) means exactly that, a persisting fact, + // not "is the store currently failing" (storeLatched already answers + // that question for the enforcement path). + lastErrMsg string logMu sync.Mutex failOpen bool // store-error policy: true falls back to fallback, false refuses the request + // statsAdmin, statsUserModel, statsLatency gate the admin-redesign + // counter families this task adds (DECISIONS: "Always-on-with-admin" + // vs the two opt-in sub-flags) — set once, by newConfiguredLimiter, + // from Config.Admin/Config.Admin.Stats; a limiter built directly via + // newLimiter (most tests) defaults every one of these to false, so + // existing tests observe byte-identical behavior to before this task + // unless they opt in explicitly. + // + // - statsAdmin: adminEnabled(cfg) — gates every "always-on-with- + // admin" family (last-seen, hourly provider attempt/fail/timeout, + // failover, rej/hour, r402, target x caller, cache counters). + // - statsUserModel: statsAdmin && Config.Admin.Stats.UserModel + // (default off) — gates the opt-in per-(user, model) breakdown. + // - statsLatency: statsAdmin && Config.Admin.Stats.Latency (default + // off) — gates the opt-in per-bucket latency counters. Already + // ANDed with statsAdmin at construction, so a call site need only + // check this one field, never both. + statsAdmin bool + statsUserModel bool + statsLatency bool } // rejectionScope is the (kind, id) key rejectionCounter accumulates @@ -814,6 +1057,7 @@ func newLimiter(store counterStore, failOpen bool) *limiter { logf: func(string, ...any) {}, failOpen: failOpen, spawnTokens: make(chan struct{}, providerAttemptSpawnCap), + lastSeen: &lastSeenGate{}, } l.spawn = func(f func()) { select { @@ -1050,6 +1294,22 @@ func (l *limiter) failPolicyIncrAndGetMulti(entries []counterIncr, reads []strin return incrVals, readVals, true } +// countAsync spawns entries as one fire-and-forget storeIncrMulti batch, +// off the request's hot path (l.spawn) — the shared primitive behind every +// async-only counter family this task adds: a cache HIT's chit/csave +// (recordCacheHit) and a refused-unpriced-model's r402 (recordUnpriced402), +// mirroring recordProviderAttempt's own long-established "build entries, +// then spawn the write" shape, factored out here so it is written once. A +// nil/empty entries slice spawns nothing at all. +func (l *limiter) countAsync(entries []counterIncr) { + if len(entries) == 0 { + return + } + l.spawn(func() { + l.storeIncrMulti(entries) + }) +} + // storeDownViolation is the violation checkAndCount returns when the // configured store is unreachable and failOpen is false: the // request is refused instead of silently enforcing limits against a @@ -1300,6 +1560,13 @@ func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation { counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, ) } + // Last-seen (Q6, DECISIONS) no longer rides this batch (P9 fix, admin + // dashboard redesign verify round): it used to be appended here, + // unconditionally, which meant a REJECTED request still moved that + // scope's last-seen timestamp forward before checkAndCount even knew + // whether it was about to refuse the request. recordLastSeen, below, + // is now called by every admission call site's own "violation == nil" + // branch instead — see its own doc comment. probes, reads := buildBudgetProbes(scopes, now) incrVals, readVals, ok := l.storeIncrAndGetMulti(entries, reads) @@ -1349,6 +1616,47 @@ func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation { return nil } +// recordLastSeen writes the absolute last-seen SET entries lastSeenEntries +// (below) builds for scopes, fire-and-forget (countAsync/l.spawn) — P9 fix +// (admin dashboard redesign verify round): every admitRequestForRoute- +// shaped call site calls this ONLY from its own "violation == nil" branch, +// after checkAndCount has already reported admission, so a request +// checkAndCount refuses never touches last-seen at all. Moving it off +// checkAndCount's own synchronous batch adds no new round trip to the +// admitted path either — countAsync already spawns off the request's own +// goroutine, the same shape recordCacheHit/recordUnpriced402 use. +func (l *limiter) recordLastSeen(scopes []limitScope, now time.Time) { + l.countAsync(l.lastSeenEntries(scopes, now)) +} + +// lastSeenEntries returns the absolute last-seen SET entries recordLastSeen +// (above) spawns — one per "user" or "group" scope in scopes (never the +// synthetic total scope: a fleet-wide "when was anyone last seen" has no +// meaning), skipped entirely when admin stats are off (l.statsAdmin) or +// this replica already wrote one for that scope within lastSeenGateThrottle +// (l.lastSeen.due). TTL is monthWindowTTL +// (400d, matching plan §1.2's last-seen retention) with no enforceTTL +// floor (0): unlike a windowed counter, a last-seen value has no +// correctness-driven minimum lifetime — memoryStoreMaxTTL's own ceiling is +// free to shorten the in-process fallback's copy, since it is refreshed on +// every admitted request that scope makes anyway. +func (l *limiter) lastSeenEntries(scopes []limitScope, now time.Time) []counterIncr { + if !l.statsAdmin { + return nil + } + var out []counterIncr + for _, sc := range scopes { + if sc.kind != "user" && sc.kind != "group" { + continue + } + if !l.lastSeen.due(sc.kind, sc.id, now) { + continue + } + out = append(out, counterIncr{key: lastSeenKey(sc.kind, sc.id), delta: now.Unix(), ttl: monthWindowTTL, absolute: true}) + } + return out +} + // settleRejection compensates the req:min/req:day/req:hour increments // checkAndCount already made for every scope OTHER than violatedIdx — // exactly what rollbackOtherScopeCounts (its pre-F4 name) did — AND, in @@ -1362,7 +1670,7 @@ func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation { // before F4 existed — see checkAndCount's own doc comment for the round- // trip accounting this preserves. func (l *limiter) settleRejection(entries []counterIncr, violatedIdx int, scopes []limitScope, wb windowBuckets, et enforceTTLSet) { - comp := make([]counterIncr, 0, len(scopes)*checkAndCountKeysPerScope) + comp := make([]counterIncr, 0, len(scopes)*checkAndCountKeysPerScope+4) totalPresent := false for i, sc := range scopes { if i == violatedIdx { @@ -1377,21 +1685,35 @@ func (l *limiter) settleRejection(entries []counterIncr, violatedIdx int, scopes } } violated := scopes[violatedIdx] - comp = append(comp, rejectionCounterIncr(violated.kind, violated.id, wb.day, et.day)) + comp = append(comp, rejectionCounterIncr(violated.kind, violated.id, windowDay, wb.day, et.day)) if totalPresent { - comp = append(comp, rejectionCounterIncr(totalScopeKind, totalScopeID, wb.day, et.day)) + comp = append(comp, rejectionCounterIncr(totalScopeKind, totalScopeID, windowDay, wb.day, et.day)) + } + // rej/hour (always-on-with-admin, DECISIONS): rides this SAME + // compensation batch, never a second round trip, alongside the + // existing always-on rej/day entries above. + if l.statsAdmin { + comp = append(comp, rejectionCounterIncr(violated.kind, violated.id, windowHour, wb.hour, et.hour)) + if totalPresent { + comp = append(comp, rejectionCounterIncr(totalScopeKind, totalScopeID, windowHour, wb.hour, et.hour)) + } } if _, ok := l.storeIncrMulti(comp); !ok { l.logf("limits: rollback/rejection-accounting batch failed after a rejection; other scopes' req:min/day/hour counters may be over-counted by 1, and rejectionsPerDay may undercount, for the current window") } } -// rejectionCounterIncr builds the single rej:day counterIncr entry -// settleRejection adds per scope it attributes a rejection to — delta 1, -// ttl/enforceTTL matching every other day-window counter this file writes -// (dayWindowTTL/et.day, exactly like checkAndCount's own req:day entry). -func rejectionCounterIncr(kind, id, dayBucket string, enforceTTL time.Duration) counterIncr { - return counterIncr{key: windowKeyForBucket(kind, id, metricRej, windowDay, dayBucket), delta: 1, ttl: dayWindowTTL, enforceTTL: enforceTTL} +// rejectionCounterIncr builds the single rej counterIncr entry +// settleRejection adds per scope it attributes a rejection to, for window +// (windowDay, always-on; windowHour, admin-gated) — delta 1, ttl the +// window's own matching constant (dayWindowTTL/hourWindowTTL), enforceTTL +// the caller's own et.day/et.hour. +func rejectionCounterIncr(kind, id, window, bucket string, enforceTTL time.Duration) counterIncr { + ttl := dayWindowTTL + if window == windowHour { + ttl = hourWindowTTL + } + return counterIncr{key: windowKeyForBucket(kind, id, metricRej, window, bucket), delta: 1, ttl: ttl, enforceTTL: enforceTTL} } // requestLimitViolation reports a violation when count (already @@ -1423,7 +1745,65 @@ func requestLimitViolation(sc limitScope, name string, limit, count int64, windo // silently, rather than counting it in the fallback — dropping avoids // double-counting once the store recovers, the same reasoning that // applied per-key before this call became one batch. -func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) { +// accountExtras carries the per-call, non-scope facts accountWith's own +// extra counter families need — never a limitScope, since none of these +// are things a request is admitted or budgeted against, only recorded +// alongside its usage. account (limits_helpers_test.go, admin-redesign +// WP-G cleanup: every production call site now calls accountWith +// directly, so account itself is test-only) is accountWith with a +// zero-value accountExtras, so every pre-existing caller's behavior is +// byte-for-byte unchanged. +type accountExtras struct { + // provider is the serving candidate's own provider name — accountWith + // writes its opt-in latency buckets (statsLatency) under this, and its + // failover-from entry (below) is keyed by the PREVIOUS candidate's + // provider name, not this one. + provider string + // failoverFrom is the previous candidate's provider name, set only + // when this account call is for a candidate reached after an earlier + // one failed (i > 0 in runMeteredCall's own loop) — accountWith + // increments prov/{failoverFrom}:fover when non-empty and admin stats + // are on. + failoverFrom string + // lat/hasLat carry one completed upstream watchdogBody's latency + // sample (metrics.go), when withLatencyRecorder was wired for this + // call and it actually fired — hasLat false means no sample arrived + // (metrics disabled and stats.latency off, or the watchdog never + // closed). + lat latencySample + hasLat bool + // cacheMiss reports whether THIS candidate's own cacheable response- + // cache lookup (routes_unified.go) was attempted and missed — always + // false for a request the cache was never consulted for. A HIT never + // reaches accountWith at all (routes_unified.go returns before ever + // calling it); recordCacheHit, below, is that path's own async + // counterpart. + cacheMiss bool + // noModelHistograms suppresses accountWith's own model-scope latency + // AND user x model (umodel) writes even when scopes DOES carry a + // kindModel scope (P13 fix, admin dashboard redesign verify round: + // plan §2(c) says passthrough gets "provider histograms only", but + // routes_passthrough.go always passed the served model's scope, so + // latency (28 metrics x hour/day) and umodel entries were ALSO + // written keyed by an upstream-echoed model id — bounded only by + // length/control-char, never by a configured catalog, and never read + // back anywhere: usage/models and usermodel totals both rank off the + // CONFIGURED catalog, not whatever a passthrough response happened to + // name). The base req/tokin/tokout/cost model-scope counters (the + // per-scope loop above, driven purely by which scopes the caller + // passed in) are unaffected — only the two opt-in, per-model-keyed + // families this flag names. + noModelHistograms bool +} + +// accountWith is account generalized with accountExtras (admin-redesign +// WP-A): every scope's own req/tokin/tokout/cost entries below are +// byte-for-byte what account always built; x's own fields each add AT +// MOST a few more counterIncr entries to the SAME batch, never a second +// storeIncrMulti round trip — see accountExtras' own field docs and this +// function's per-block comments below for exactly what each one writes +// and when. +func (l *limiter) accountWith(scopes []limitScope, u usage, costMicros int64, x accountExtras) { now := l.now() wb := bucketsFor(now) et := enforceTTLsFor() @@ -1457,8 +1837,20 @@ func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) { } } - entries := make([]counterIncr, 0, len(scopes)*12) + entries := make([]counterIncr, 0, len(scopes)*12+16) + userScopeCount := 0 + var userScopeID string + hasModelScope := false + var modelCanonical string for _, sc := range scopes { + if sc.kind == "user" { + userScopeCount++ + userScopeID = sc.id + } + if sc.kind == kindModel { + hasModelScope = true + modelCanonical = sc.id + } // A kindModel scope is the one scope kind checkAndCount never // sees: it is resolved post-response, because only then is the // SERVING model known (failover can move a request to another @@ -1497,28 +1889,192 @@ func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) { ) } } + + // (a) user x model (opt-in, admin.stats.userModel): only when exactly + // ONE user scope is present (a multi-group principal's several group + // scopes never trigger this — there is still only one user) and a + // served model is known. <=8 more entries: req always (a served + // request counts even with zero usage, mirroring kindModel's own + // unconditional req write above), tokin/tokout/cost only when nonzero + // — day and month only, no hour (plan §1.2's own umodel row). Media's + // account(withModelScope(nil, ...), usage{}, 0) call has no user scope + // at all (scopes is nil there) and so never reaches this block. + if l.statsUserModel && userScopeCount == 1 && hasModelScope && !x.noModelHistograms { + id := userModelScopeID(userScopeID, modelCanonical) + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindUserModel, id, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + counterIncr{key: windowKeyForBucket(kindUserModel, id, metricReq, windowMonth, wb.month), delta: 1, ttl: monthWindowTTL, enforceTTL: et.month}, + ) + if u.prompt != 0 { + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindUserModel, id, metricTokIn, windowDay, wb.day), delta: u.prompt, ttl: dayWindowTTL, enforceTTL: et.day}, + counterIncr{key: windowKeyForBucket(kindUserModel, id, metricTokIn, windowMonth, wb.month), delta: u.prompt, ttl: monthWindowTTL, enforceTTL: et.month}, + ) + } + if u.completion != 0 { + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindUserModel, id, metricTokOut, windowDay, wb.day), delta: u.completion, ttl: dayWindowTTL, enforceTTL: et.day}, + counterIncr{key: windowKeyForBucket(kindUserModel, id, metricTokOut, windowMonth, wb.month), delta: u.completion, ttl: monthWindowTTL, enforceTTL: et.month}, + ) + } + if costMicros != 0 { + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindUserModel, id, metricCost, windowDay, wb.day), delta: costMicros, ttl: dayWindowTTL, enforceTTL: et.day}, + counterIncr{key: windowKeyForBucket(kindUserModel, id, metricCost, windowMonth, wb.month), delta: costMicros, ttl: monthWindowTTL, enforceTTL: et.month}, + ) + } + } + + // (c) failover (always-on-with-admin): +1 to the PREVIOUS candidate's + // own prov/{from}:fover hour+day counters, every time runMeteredCall's + // own loop moves to a next candidate after an earlier one failed. + if x.failoverFrom != "" && l.statsAdmin { + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindProvider, x.failoverFrom, metricFover, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + counterIncr{key: windowKeyForBucket(kindProvider, x.failoverFrom, metricFover, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + ) + } + + // (c) latency (opt-in, admin.stats.latency): the serving provider's + // own bucket, plus the served model's own bucket when one is known — + // each hour+day, duration always, TTFB only when the sample carries + // one (a body that never delivered a successful byte has nothing + // meaningful to report there). + if x.hasLat && l.statsLatency { + if x.provider != "" { + entries = append(entries, latencyCounterEntries(kindProvider, x.provider, x.lat, wb, et)...) + } + if hasModelScope && !x.noModelHistograms { + entries = append(entries, latencyCounterEntries(kindModel, modelCanonical, x.lat, wb, et)...) + } + } + + // (d) cache miss (always-on-with-admin, cache already required to be + // enabled for cacheMiss to ever be true at all — routes_unified.go + // only sets it inside its own cacheable branch): total cmiss hour+day, + // plus the served model's own cmiss at day only (matching every other + // model-scope cache metric's day-only granularity — recordCacheHit's + // identical rule for chit/csave). + if x.cacheMiss && l.statsAdmin { + entries = append(entries, + counterIncr{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricCmiss, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + counterIncr{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricCmiss, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + ) + if hasModelScope { + entries = append(entries, counterIncr{key: windowKeyForBucket(kindModel, modelCanonical, metricCmiss, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}) + } + } + if len(entries) == 0 { - return // every metric was zero for every scope — nothing to write + return // every metric was zero for every scope, and no extra was set — nothing to write } l.storeIncrMulti(entries) // no error return by contract (doc comment above); ok is intentionally discarded } -// countTargetRequests increments MULTIPLE target scopes' per-target request -// counters (Feature B, v0.21) — each at min, hour, day, AND month, the only -// scope kind this package tracks a month window of REQUESTS for — in ONE -// storeIncrMulti call. kind is targetScopeKind's own output ("mcp" or -// scopeKindAgent — mcp_a2a.go), id is the configured target name; both come -// embedded in windowKey's own key string, so a target scope can never -// collide with a user/group/total scope of the same id even by coincidence -// — kind is part of the key, not just a struct field. A scope's own -// `limits` field is ignored entirely (never read); callers pass plain -// {kind, id} pairs. +// latencyCounterEntries returns the hour+day duration-bucket entries for +// (kind, id) — plus the matching TTFB-bucket entries when lat.hasTTFB — +// accountWith's own opt-in latency block (above) appends for the serving +// provider and, separately, for the served model. latencyBucketIndex +// (metrics.go) already folds "past every configured bound" into its own +// returned index (len(latencyBucketBounds)), so no separate overflow +// branch is needed here. +func latencyCounterEntries(kind, id string, lat latencySample, wb windowBuckets, et enforceTTLSet) []counterIncr { + durIdx, _ := latencyBucketIndex(lat.duration) + durMetric := latencyDurationMetric(durIdx) + out := []counterIncr{ + {key: windowKeyForBucket(kind, id, durMetric, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + {key: windowKeyForBucket(kind, id, durMetric, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + } + if lat.hasTTFB { + ttfbIdx, _ := latencyBucketIndex(lat.ttfb) + ttfbMetric := latencyTTFBMetric(ttfbIdx) + out = append(out, + counterIncr{key: windowKeyForBucket(kind, id, ttfbMetric, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + counterIncr{key: windowKeyForBucket(kind, id, ttfbMetric, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + ) + } + return out +} + +// recordCacheHit accounts one response-cache HIT (Q7, DECISIONS: +// "cache-hit body parse only when cache stats on: accept") — total +// chit/csave at hour and day, plus the served model's own chit/csave at +// day only (matching accountWith's own cmiss day-only rule for a model +// scope). Fire-and-forget (countAsync/l.spawn): a HIT has already written +// its full response to the client before routes_unified.go's own call +// site reaches this, so nothing here can ever delay it. csave entries are +// skipped when savedMicros is 0 (an unpriced or free model), mirroring +// account's own "skip a zero direction" rule. +func (l *limiter) recordCacheHit(canonical string, savedMicros int64) { + if !l.statsAdmin { + return + } + now := l.now() + wb := bucketsFor(now) + et := enforceTTLsFor() + entries := []counterIncr{ + {key: windowKeyForBucket(totalScopeKind, totalScopeID, metricChit, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + {key: windowKeyForBucket(totalScopeKind, totalScopeID, metricChit, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + } + if savedMicros != 0 { + entries = append(entries, + counterIncr{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricCsave, windowHour, wb.hour), delta: savedMicros, ttl: hourWindowTTL, enforceTTL: et.hour}, + counterIncr{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricCsave, windowDay, wb.day), delta: savedMicros, ttl: dayWindowTTL, enforceTTL: et.day}, + ) + } + if canonical != "" { + entries = append(entries, counterIncr{key: windowKeyForBucket(kindModel, canonical, metricChit, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}) + if savedMicros != 0 { + entries = append(entries, counterIncr{key: windowKeyForBucket(kindModel, canonical, metricCsave, windowDay, wb.day), delta: savedMicros, ttl: dayWindowTTL, enforceTTL: et.day}) + } + } + l.countAsync(entries) +} + +// recordUnpriced402 accounts one "refused: unpriced model under a cost +// budget" event (Feature F-1, security audit run-1; events.go's +// recordUnpricedRefusalEvent calls this alongside its own gatewayEvent +// record, the one chokepoint both routes_unified.go's and routes_ +// passthrough.go's identical 402 guard already share) — total r402 +// hour+day, plus the refused model's own r402 hour+day when canonical is +// known. Fire-and-forget (countAsync/l.spawn): the request has already +// been refused with a clean 402 envelope by the time this runs. +func (l *limiter) recordUnpriced402(canonical string) { + if !l.statsAdmin { + return + } + now := l.now() + wb := bucketsFor(now) + et := enforceTTLsFor() + entries := []counterIncr{ + {key: windowKeyForBucket(totalScopeKind, totalScopeID, metricR402, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + {key: windowKeyForBucket(totalScopeKind, totalScopeID, metricR402, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + } + if canonical != "" { + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindModel, canonical, metricR402, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + counterIncr{key: windowKeyForBucket(kindModel, canonical, metricR402, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + ) + } + l.countAsync(entries) +} + +// countTargetRequestsBy increments MULTIPLE target scopes' per-target +// request counters (Feature B, v0.21) — each at min, hour, day, AND +// month, the only scope kind this package tracks a month window of +// REQUESTS for — in ONE storeIncrMulti call. kind is targetScopeKind's +// own output ("mcp" or scopeKindAgent — mcp_a2a.go), id is the +// configured target name; both come embedded in windowKey's own key +// string, so a target scope can never collide with a user/group/total +// scope of the same id even by coincidence — kind is part of the key, +// not just a struct field. A scope's own `limits` field is ignored +// entirely (never read); callers pass plain {kind, id} pairs. // // This is deliberately NOT folded into checkAndCount: a target scope // carries no limits by design (this round adds no config surface for // MCP/agent limits), so there is nothing for checkAndCount to evaluate // and no violation this call could ever produce — it is pure accounting, exactly -// like account() itself, hence the identical "no error return, ok +// like accountWith() itself, hence the identical "no error return, ok // discarded" contract. Month exists here, uniquely, because the admin // dashboard's targets endpoint (admin.go's adminTargetCountersView) // surfaces a requestsPerMonth figure no user/group admin view does; @@ -1533,7 +2089,15 @@ func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) { // each fan-out goroutine paid one round trip per server; this collects // every scope from the whole fan-out into a single call after the fan-out's // own wg.Wait(), the same "one request in, one round trip out" discipline -// checkAndCount/account already apply per scope-slice. +// checkAndCount/accountWith already apply per scope-slice. +// +// caller identity (target x caller, DECISIONS' "always-on-with-admin" +// family): when caller is non-empty and admin stats are on, ALSO appends +// kindTargetCaller req entries at day+month for every scope, in the SAME +// batch below already builds — never a second round trip. caller == "" +// (countTargetRequests/countTargetRequest, limits_helpers_test.go's +// test-only wrappers — every production call site now names a caller) +// skips this addition entirely. // // Callers: handleTargetProxy (mcp_a2a.go, a 1-element slice, once per // proxied request, after its own user/group/total admission check passes) @@ -1542,14 +2106,14 @@ func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) { // — not necessarily reached or succeeded — for tools/list's fan-out; see // mcpFederatedToolsList's own doc comment for why "attempted" is the // correct word here). -func (l *limiter) countTargetRequests(scopes []limitScope) { +func (l *limiter) countTargetRequestsBy(caller string, scopes []limitScope) { if len(scopes) == 0 { return } now := l.now() wb := bucketsFor(now) et := enforceTTLsFor() - entries := make([]counterIncr, 0, len(scopes)*4) + entries := make([]counterIncr, 0, len(scopes)*6) for _, sc := range scopes { entries = append(entries, counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min}, @@ -1557,16 +2121,22 @@ func (l *limiter) countTargetRequests(scopes []limitScope) { counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowMonth, wb.month), delta: 1, ttl: monthWindowTTL, enforceTTL: et.month}, ) + if caller != "" && l.statsAdmin { + id := targetCallerScopeID(sc.kind, sc.id, caller) + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindTargetCaller, id, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + counterIncr{key: windowKeyForBucket(kindTargetCaller, id, metricReq, windowMonth, wb.month), delta: 1, ttl: monthWindowTTL, enforceTTL: et.month}, + ) + } } l.storeIncrMulti(entries) } -// countTargetRequest is countTargetRequests for exactly one target scope — -// handleTargetProxy's own single-target shape (mcp_a2a.go), where a batch -// of one buys nothing but keeps the call site a plain two-argument call -// instead of a one-element slice literal. -func (l *limiter) countTargetRequest(kind, id string) { - l.countTargetRequests([]limitScope{{kind: kind, id: id}}) +// countTargetRequestBy is countTargetRequest (limits_helpers_test.go's +// test-only wrapper) with a caller identity attached — +// countTargetRequestsBy's own single-target shape. +func (l *limiter) countTargetRequestBy(caller, kind, id string) { + l.countTargetRequestsBy(caller, []limitScope{{kind: kind, id: id}}) } // targetCounterKeysPerScope is the number of windowKey strings @@ -1919,8 +2489,14 @@ func (l *limiter) recordProviderAttempt(provider, model string, resp *http.Respo wb := bucketsFor(now) et := enforceTTLsFor() fail := isTransient(resp, err) || isDeadlineExceeded(err) + // timeout (always-on-with-admin, below): a strict SUBSET of fail — the + // gateway's own deadline lapsed, or the watchdog's stalled-progress + // sentinel fired — reusing matchesSentinel/isDeadlineExceeded exactly + // like recordUpstreamEvent's own identical classification (events.go), + // never a third, independently-drifting check. + timeout := matchesSentinel(err, errProviderTimeout) || isDeadlineExceeded(err) - entries := make([]counterIncr, 0, 6) + entries := make([]counterIncr, 0, 14) entries = append(entries, counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvAttempt, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min}, counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvAttempt, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, @@ -1946,9 +2522,39 @@ func (l *limiter) recordProviderAttempt(provider, model string, resp *http.Respo ) } } - l.spawn(func() { - l.storeIncrMulti(entries) // no error return by contract (doc comment above, mirroring account()); ok is intentionally discarded - }) + + // Always-on-with-admin (DECISIONS): hourly attempt/fail for prov and + // provmodel, plus prov's own timeout hour+day — zero cost when admin + // is disabled, riding this SAME async batch otherwise, never a second + // round trip. + if l.statsAdmin { + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvAttempt, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + ) + if fail { + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvFail, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + ) + } + if model != "" { + id := providerModelScopeID(provider, model) + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvAttempt, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + ) + if fail { + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvFail, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + ) + } + } + if timeout { + entries = append(entries, + counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvTimeout, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvTimeout, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + ) + } + } + l.countAsync(entries) } // providerCounterKeysPerScope is the number of windowKey strings @@ -2097,8 +2703,10 @@ func (l *limiter) providerUsage(scopes []limitScope) []providerCounters { // count from 6 to 8 for the tokens-in/tokens-out split, which is exactly // the moment a silent off-by-N here would have gone unnoticed. F4 (v0.3 // dashboard task) grows it again, 8 to 9, for the fleet-wide -// rejectionsPerDay counter (metricRej) usageWindowKeys now appends. -const usageKeysPerScope = 9 +// rejectionsPerDay counter (metricRej) usageWindowKeys now appends. Grows +// once more, 9 to 10, for the last-seen counter (Q6, DECISIONS) — see +// usageWindowKeys' own doc comment for the appended field's position. +const usageKeysPerScope = 10 // scopeUsage is one scope's (a user's, a group's, or the synthetic total // scope's — totalScopeKind) current-window counter values — the admin @@ -2126,6 +2734,11 @@ type scopeUsage struct { // GET /admin/api/usage's rejectionsPerDay field. Unlike every other // field here, this counts REFUSED requests, not admitted ones. rejectionsPerDay int64 + // lastSeen is the unix-second value THIS scope's last-seen SET last + // wrote (Q6, DECISIONS) — 0 when never seen, or when admin stats were + // off for the whole life of the deployment so far, matching GET + // /admin/api/usage's own "lastSeen 0 means never/unknown" contract. + lastSeen int64 // storeDown reports whether reading any of the usageKeysPerScope // counters above failed closed (a configured store errored and // failOpen is false) — mirrors limitViolation.storeDown. Every value @@ -2139,8 +2752,12 @@ type scopeUsage struct { // req/day, tokin/day, tokin/month, tokout/day, tokout/month, cost/day, // cost/month, rej/day — matching scopeUsage's field order exactly, so // currentUsage can map storeGetMulti's result slice back to named fields -// by plain index. rej/day (F4, v0.3 dashboard task) is appended last so -// every existing index above it stays byte-for-byte unchanged. +// by plain index. rej/day (F4, v0.3 dashboard task) is appended after +// every original index; lastSeenKey (Q6, DECISIONS) is appended last of +// all — every existing index above it stays byte-for-byte unchanged. A +// last-seen key never rolls over (lastSeenKey has no window-bucket +// component), so it needs no `now` argument the way every other key here +// does; passed through only so this stays a single flat literal. func usageWindowKeys(sc limitScope, now time.Time) []string { return []string{ windowKey(sc.kind, sc.id, metricReq, windowMin, now), @@ -2152,6 +2769,7 @@ func usageWindowKeys(sc limitScope, now time.Time) []string { windowKey(sc.kind, sc.id, metricCost, windowDay, now), windowKey(sc.kind, sc.id, metricCost, windowMonth, now), windowKey(sc.kind, sc.id, metricRej, windowDay, now), + lastSeenKey(sc.kind, sc.id), } } @@ -2226,7 +2844,7 @@ func (l *limiter) currentUsage(scopes []limitScope) []scopeUsage { tokensInPerDay: v[2], tokensInPerMonth: v[3], tokensOutPerDay: v[4], tokensOutPerMonth: v[5], costPerDayMicros: v[6], costPerMonthMicros: v[7], - rejectionsPerDay: v[8], + rejectionsPerDay: v[8], lastSeen: v[9], } } return out @@ -2242,109 +2860,21 @@ type historyPoint struct { value int64 } -// modelSpanTotals reads span counter buckets per id — via -// historyBucketKeys, the identical per-id bucket walk history (below) -// uses for one scope — flattens every id's span keys into ONE -// storeGetMulti round trip, and returns each id's SUM over its own span, -// positionally (result[i] is the sum for ids[i]). F2, v0.3 dashboard -// task: backs GET /admin/api/usage/models' optional ?span= parameter, so -// the Charts view's "Models" ranking can show a window wider than the -// current bucket alone. modelTotals (below) is the span=1 special case, -// unchanged in behavior from before this function existed. -// -// ok is false when the store read fails, exactly as history/modelTotals -// already promise, so the caller answers 503 rather than presenting a -// partial or fallback-served sum as a genuine ranking. An empty ids slice -// is not a store read at all: it returns an empty result and ok, never a -// round trip. -// -// configuredStoreDown is checked alongside storeGetMulti's own ok -// (finding F5, 2026-09 review, applying currentUsage/providerUsage's own -// fix here too): with the default failOpen=true, ok alone stays true -// during a store outage — storeGetMulti silently reads the in-process -// fallback instead, which was never the ranking's real per-model source -// of truth — so without this check GET /admin/api/usage/models would -// present a fail-open read of near-empty fallback data as a genuine -// "these models were barely used" ranking instead of the 503 this -// function's own doc comment already promises for a store failure. -func (l *limiter) modelSpanTotals(ids []string, metric, window string, now time.Time, span int) ([]int64, bool) { - if len(ids) == 0 { - return nil, true - } - allKeys := make([]string, 0, len(ids)*span) - for _, id := range ids { - keys, _ := historyBucketKeys(kindModel, id, metric, window, now, span) - allKeys = append(allKeys, keys...) - } - vals, ok := l.storeGetMulti(allKeys) - if !ok || len(vals) != len(allKeys) || l.configuredStoreDown() { - return nil, false - } - out := make([]int64, len(ids)) - for i := range ids { - var sum int64 - for _, v := range vals[i*span : i*span+span] { - sum += v - } - out[i] = sum - } - return out, true -} - -// modelTotals reads ONE counter per id — metric at window's CURRENT -// bucket — the modelSpanTotals span=1 special case: historyBucketKeys' -// own i=0/span=1 step lands on exactly the same key windowKey(kindModel, -// id, metric, window, now) would have built directly, so this wrapper is -// byte-for-byte the pre-F2 implementation's behavior. -func (l *limiter) modelTotals(ids []string, metric, window string) ([]int64, bool) { - return l.modelSpanTotals(ids, metric, window, l.now(), 1) -} - -// modelSpanTotalsMulti is modelSpanTotals generalized to several metrics -// read in ONE storeGetMulti round trip — GET /admin/api/usage/models' -// detail=1 mode (free-models feature) needs all four metrics -// (req/tokin/tokout/cost) for every candidate id, not just the one the -// ranking sorts by, and a per-metric modelSpanTotals call would cost 4 -// round trips instead of 1. Keys are built id-major, metric second, span -// innermost — out[m][i] is metrics[m]'s span-sum for ids[i], positional -// in both dimensions, mirroring modelSpanTotals' own result[i] contract. -// ok is false on any store read failure, exactly like modelSpanTotals — -// a detail read that only partially succeeds is never presented as -// complete data. An empty ids slice is not a store read at all, matching -// modelSpanTotals' own contract. -func (l *limiter) modelSpanTotalsMulti(ids, metrics []string, window string, now time.Time, span int) ([][]int64, bool) { - if len(ids) == 0 { - return nil, true - } - allKeys := make([]string, 0, len(ids)*len(metrics)*span) - for _, id := range ids { - for _, metric := range metrics { - keys, _ := historyBucketKeys(kindModel, id, metric, window, now, span) - allKeys = append(allKeys, keys...) - } - } - vals, ok := l.storeGetMulti(allKeys) - if !ok || len(vals) != len(allKeys) || l.configuredStoreDown() { - return nil, false - } - out := make([][]int64, len(metrics)) - for m := range out { - out[m] = make([]int64, len(ids)) - } - stride := len(metrics) * span - for i := range ids { - base := i * stride - for m := range metrics { - mBase := base + m*span - var sum int64 - for _, v := range vals[mBase : mBase+span] { - sum += v - } - out[m][i] = sum - } - } - return out, true -} +// modelSpanTotals/modelTotals/modelSpanTotalsMulti (limits_helpers_test.go, +// admin-redesign WP-G cleanup) read span counter buckets per id — via +// historyBucketKeys/historyBucketKeysAt, the identical per-id bucket walk +// history (below) uses for one scope — flattening every id's span keys +// into ONE storeGetMulti round trip. GET /admin/api/usage/models' actual +// production read path is admin.go's chunkedModelSpanTotals/ +// chunkedModelSpanTotalsMulti, which read directly via +// historyBucketKeysAt+storeGetMulti in bounded-size chunks instead of +// delegating here (admin.go's own doc comment on chunkedModelSpanTotals +// explains why: a single limiter.modelSpanTotalsMulti call has no chunk +// boundary, so a wide model catalog could build one storeGetMulti larger +// than any real Redis pipeline should be). These three stay as test-only +// helpers because limits_test.go's own coverage of historyBucketKeys/ +// historyBucketKeysAt's span and offset semantics is written against +// them. // historyStepBack returns the instant window's bucket was current i steps // before now: i=0 is now's own (current, possibly partial) bucket, i=1 @@ -2370,22 +2900,157 @@ func historyStepBack(now time.Time, window string, i int) time.Time { } } -// historyBucketKeys returns the span counterStore keys and their matching -// bucket label strings for (kind, id, metric, window), stepping back from -// now one whole window unit at a time — oldest first, the current -// (possibly partial) bucket last, matching GET /admin/api/usage/history's -// "inclusive of current bucket, oldest-first" contract. -func historyBucketKeys(kind, id, metric, window string, now time.Time, span int) (keys, buckets []string) { +// historyBucketKeysAt is historyBucketKeys generalized with an offset +// (WP-B's own comparison-mode reads: GET /admin/api/usage/models and +// /usage/series' ?offset= parameter): the returned span ends offset window +// units before now instead of at now itself, letting a caller read an +// EARLIER span of the identical width for a period-over-period +// comparison, without duplicating historyStepBack's own calendar-aware +// month arithmetic a second time. offset=0 is byte-for-byte +// historyBucketKeys' own pre-existing behavior — composing historyStepBack +// twice (once for offset, once per bucket within the span) is +// associative with calling it once for their sum, since historyStepBack's +// own month case always anchors on the 1st of whatever month it is +// applied to, and its result here is always itself a 1st-of-month date. +func historyBucketKeysAt(kind, id, metric, window string, now time.Time, span, offset int) (keys, buckets []string) { + base := now + if offset > 0 { + base = historyStepBack(now, window, offset) + } keys = make([]string, span) buckets = make([]string, span) for i := 0; i < span; i++ { - t := historyStepBack(now, window, span-1-i) + t := historyStepBack(base, window, span-1-i) keys[i] = windowKey(kind, id, metric, window, t) buckets[i] = bucketFor(t, window) } return keys, buckets } +// historyBucketKeys returns the span counterStore keys and their matching +// bucket label strings for (kind, id, metric, window), stepping back from +// now one whole window unit at a time — oldest first, the current +// (possibly partial) bucket last, matching GET /admin/api/usage/history's +// "inclusive of current bucket, oldest-first" contract. A thin offset=0 +// wrapper around historyBucketKeysAt, above — every existing caller of +// this name keeps calling it, and its own behavior is unchanged. +func historyBucketKeys(kind, id, metric, window string, now time.Time, span int) (keys, buckets []string) { + return historyBucketKeysAt(kind, id, metric, window, now, span, 0) +} + +// chunkedGetMulti reads keys via one or more storeGetMulti round trips, +// each capped at chunkSize keys, and returns the values concatenated back +// in the SAME order as keys (P6 fix, admin dashboard redesign verify +// round: GET /admin/api/usage/series and spanTotalsMulti's own predecessors +// each used to build ONE unbounded storeGetMulti pipeline regardless of +// how many keys the request's own scope/span/offset/metric combination +// worked out to — holding the shared Redis connection mutex, respClient +// (resp.go), for however long that single oversized pipeline took, while +// live traffic's own checkAndCount/account calls queued behind it). +// Chunking a flat key list this way is safe for a caller that does not +// need chunk boundaries aligned to any larger unit (e.g. one id's own +// span): values come back positionally identical to an unchunked read, +// this just bounds how many keys any ONE round trip carries. Any chunk +// that fails (storeDown or a mismatched read) fails the whole call — a +// series silently missing an arbitrary subset of its own buckets is worse +// than a 503, the same "no partial answer" rule every admin stats reader +// in this package already follows. +func (l *limiter) chunkedGetMulti(keys []string, chunkSize int) ([]int64, bool) { + if chunkSize < 1 { + chunkSize = len(keys) + } + out := make([]int64, 0, len(keys)) + for len(keys) > 0 { + n := chunkSize + if n > len(keys) { + n = len(keys) + } + vals, ok := l.storeGetMulti(keys[:n]) + if !ok || len(vals) != n || l.configuredStoreDown() { + return nil, false + } + out = append(out, vals...) + keys = keys[n:] + } + return out, true +} + +// spanTotalsMulti is the ONE production "per id, per metric, sum over a +// span of window buckets" reader every admin stats endpoint that needs a +// span TOTAL (as opposed to GET /admin/api/usage/series' own per-bucket +// values, which read via chunkedGetMulti directly instead) now shares +// (P12 fix, admin dashboard redesign verify round: this exact shape used +// to exist three times over — stats_read.go's own readSpanTotals, admin.go's +// chunkedModelSpanTotals/chunkedModelSpanTotalsMulti hardcoded to +// kind=kindModel, and this file's own modelSpanTotalsMulti, which drifted +// to a test-only helper (limits_helpers_test.go) once WP-B's rewrite +// stopped calling it from production, so limits_test.go's own coverage of +// the offset/chunking semantics no longer exercised what actually ran in +// production). ids may name any kind's scopes; kind is fixed per call the +// same way historyBucketKeysAt itself takes one kind per call. +// +// Reads are chunked at usageModelsChunkKeys (admin.go) keys per +// storeGetMulti round trip, sized so each chunk's own key count +// (len(chunk)*len(metrics)*span) stays at or under that bound — mirroring +// chunkedModelSpanTotalsMulti's own pre-existing chunking math, generalized +// past kindModel. 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 ids splits into. Any chunk that fails fails the whole +// call, for the identical "no partial ranking" reason chunkedGetMulti's own +// doc comment gives. +// +// An empty ids or metrics slice (or span < 1) returns immediately with +// each metric's own nil slice and ok=true — no store read at all, matching +// every span-total reader's pre-existing "empty input never touches the +// store" contract (TestModelSpanTotals_EmptyIDs_NoStoreRead pins this +// against an always-erroring store). +func (l *limiter) spanTotalsMulti(kind string, ids, metrics []string, window string, now time.Time, span, offset int) ([][]int64, bool) { + out := make([][]int64, len(metrics)) + if len(ids) == 0 || len(metrics) == 0 || span < 1 { + return out, true + } + perChunk := usageModelsChunkKeys / (span * len(metrics)) + if perChunk < 1 { + perChunk = 1 + } + for m := range out { + out[m] = make([]int64, 0, len(ids)) + } + remaining := ids + for len(remaining) > 0 { + n := perChunk + if n > len(remaining) { + n = len(remaining) + } + chunk := remaining[:n] + allKeys := make([]string, 0, len(chunk)*len(metrics)*span) + for _, id := range chunk { + for _, metric := range metrics { + keys, _ := historyBucketKeysAt(kind, id, metric, window, now, span, offset) + allKeys = append(allKeys, keys...) + } + } + vals, ok := l.chunkedGetMulti(allKeys, usageModelsChunkKeys) + if !ok || len(vals) != len(allKeys) { + return nil, false + } + stride := len(metrics) * span + for i := range chunk { + base := i * stride + for m := range metrics { + mBase := base + m*span + var sum int64 + for _, v := range vals[mBase : mBase+span] { + sum += v + } + out[m] = append(out[m], sum) + } + } + remaining = remaining[n:] + } + return out, true +} + // history returns span counter values for (kind, id, metric, window) // ending at now, oldest-first — GET /admin/api/usage/history's data // source (v0.2 data-layer task). It computes every bucket key up front diff --git a/limits_helpers_test.go b/limits_helpers_test.go index 77530d5..b5095ba 100644 --- a/limits_helpers_test.go +++ b/limits_helpers_test.go @@ -1,92 +1,191 @@ package traefikllmgateway -import "time" +import ( + "fmt" + "time" +) -// This file restores single-key test conveniences for limiter/memoryStore -// counter operations that no longer have any production caller (deadcode -// audit, 2026-09 review): checkAndCount/account moved onto the batched -// incrMulti/getMulti/incrAndGetMulti paths in the 2026-08-21/22 perf -// reviews, which left incrBy/get — and the limiter-level helpers built on -// them (storeIncrBy, storeGet, failPolicyIncrBy, failPolicyGet, incrCounter, -// getCounter) — with no caller anywhere in production. They were removed -// from limits.go and from the counterStore interface. Every helper here is -// implemented via the SAME batched production path a single-key call would -// have used (a one-entry counterIncr slice, or a one-key string slice), so -// it exercises the identical fail-open/fail-closed/latch policy and EXPIRE- -// confirmation logic the removed originals did — not a parallel -// reimplementation. This keeps the ~130 existing test call sites (across -// limits_test.go and admin_test.go) compiling and passing unchanged. +// This file holds test-only helpers for limits.go functions that +// admin-redesign WP-G's deadcode cleanup removed from production: every +// production call site was rewritten to call the more general +// replacement directly (accountWith, countTargetRequestsBy/ +// countTargetRequestBy, admin.go's chunkedModelSpanTotals/ +// chunkedModelSpanTotalsMulti), leaving these exact shapes reachable only +// from tests. Moving them here — same signatures, same bodies — keeps +// `deadcode ./...` clean (they are gone from the production build graph) +// while `deadcode -test ./...` stays clean too (tests still call them +// directly) and every pre-existing test keeps its original coverage of +// the behavior these names document. +// +// storeIncrBy/storeGet/incrCounter below are a separate, older instance +// of the identical pattern (counterStore's own doc comment, limits.go: +// "counterStore had single-key incrBy/get methods until the 2026-09 +// deadcode audit ... the tests that needed a single-key call now build a +// one-entry incrMulti batch or a one-key getMulti read instead (see +// limits_helpers_test.go ...)"), naming this exact file as their home; +// this pass fills that gap. -// incrBy is a test-only single-key counterpart to memoryStore.incrMulti, -// restoring the pre-batching direct-increment API for memoryStore-level -// unit tests that need a bare key with no window-derived enforceTTL floor -// (incrMulti's own callers always supply one via newCounterIncr; a bare -// test key has none, matching the removed production incrBy's own -// no-floor clamp). +// enforceTTLFor returns the minimum TTL window's own counter must stay +// alive for to enforce correctly — see clampTTL's own doc comment +// (limits.go) for why the floor must always win a conflict with the +// ceiling. Production code builds all four windows' floors at once via +// enforceTTLsFor (limits.go) instead of calling this per window; the +// tests below pin enforceTTLsFor's four values against this switch +// directly, and clampTTL's own tests use it to build per-window floor +// values as clampTTL inputs. +func enforceTTLFor(window string) time.Duration { + switch window { + case windowMin: + return enforceTTLMinWindow + case windowHour: + return enforceTTLHourWindow + case windowDay: + return enforceTTLDayWindow + case windowMonth: + return enforceTTLMonthWindow + default: + panic(fmt.Sprintf("llmgateway: enforceTTLFor: unknown window %q", window)) + } +} + +// account records u's prompt/completion tokens and costMicros with no +// extra counter families — accountWith(scopes, u, costMicros, +// accountExtras{}). Every production call site now calls accountWith +// directly; this wrapper keeps every pre-existing test's three-argument +// call site compiling and behaving byte-for-byte the same as before +// accountExtras existed. +func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) { + l.accountWith(scopes, u, costMicros, accountExtras{}) +} + +// countTargetRequests increments MULTIPLE target scopes' per-target +// request counters with no caller identity attached — +// countTargetRequestsBy(limits.go) with caller="". Every production call +// site now names a caller and calls countTargetRequestsBy directly; this +// wrapper keeps tests exercising the caller-less shape unchanged. +func (l *limiter) countTargetRequests(scopes []limitScope) { + l.countTargetRequestsBy("", scopes) +} + +// countTargetRequest is countTargetRequests for exactly one target scope. +func (l *limiter) countTargetRequest(kind, id string) { + l.countTargetRequests([]limitScope{{kind: kind, id: id}}) +} + +// modelSpanTotals reads span counter buckets per id, positionally +// (result[i] is the sum for ids[i]) — a single-metric convenience over +// the production spanTotalsMulti reader (limits.go; P12 fix, admin +// dashboard redesign verify round: this used to be its own separate +// unchunked implementation, exercising nothing chunkedModelSpanTotals +// (admin.go) actually runs in production — spanTotalsMulti is now the +// ONE production reader both call). Kept here as a test convenience +// purely so limits_test.go's many single-metric call sites did not all +// need rewriting to unpack spanTotalsMulti's [][]int64 by hand. +func (l *limiter) modelSpanTotals(ids []string, metric, window string, now time.Time, span int) ([]int64, bool) { + out, ok := l.spanTotalsMulti(kindModel, ids, []string{metric}, window, now, span, 0) + if !ok { + return nil, false + } + return out[0], true +} + +// modelTotals reads ONE counter per id — metric at window's CURRENT +// bucket — the modelSpanTotals span=1 special case. +func (l *limiter) modelTotals(ids []string, metric, window string) ([]int64, bool) { + return l.modelSpanTotals(ids, metric, window, l.now(), 1) +} + +// incrBy restores memoryStore's single-key increment API removed from +// production in the 2026-09 deadcode audit (applyIncr's own doc comment, +// limits.go): checkAndCount/accountWith moved onto the batched incrMulti +// in the 2026-08-21 perf review, leaving incrBy with no production +// caller. Clamped with no enforcement floor (clampTTL(ttl, 0)) — a bare +// key carries no (kind, id, metric, window) tuple to derive one from, +// unlike incrMulti's own per-entry enforceTTL floor. func (m *memoryStore) incrBy(key string, n int64, ttl time.Duration) (int64, error) { return m.applyIncr(key, n, clampTTL(ttl, 0)), nil } -// storeIncrBy is a test-only single-key counterpart to storeIncrMulti, -// letting the limiter's fail-open/fail-closed/latch tests exercise that -// policy against one key without building a one-entry counterIncr slice at -// every call site. Delegates to storeIncrMulti itself, so it drives the -// exact same policy branches (nil store / latched / store error / store -// success) a single-entry batch would. -func (l *limiter) storeIncrBy(key string, n int64, ttl time.Duration) (v int64, ok bool) { - vs, ok := l.storeIncrMulti([]counterIncr{{key: key, delta: n, ttl: ttl}}) - if !ok || len(vs) == 0 { - return 0, ok - } - return vs[0], true -} - -// storeGet mirrors storeIncrBy for a read, delegating to storeGetMulti. -func (l *limiter) storeGet(key string) (v int64, ok bool) { - vs, ok := l.storeGetMulti([]string{key}) - if !ok || len(vs) == 0 { - return 0, ok - } - return vs[0], true -} - -// failPolicyIncrBy applies the limiter's fail-open/fail-closed policy for -// an incrBy the caller has decided not to attempt against the configured -// store (it just failed, or the store-down latch is open): failOpen=true -// counts key in the fallback instead; failOpen=false reports the -// operation as failed. Exercised directly (not via storeIncrBy/ -// storeIncrMulti) by tests that isolate this branching from the store-call -// decision above it. -func (l *limiter) failPolicyIncrBy(key string, n int64, ttl time.Duration) (int64, bool) { - if !l.failOpen { - return 0, false - } - v, _ := l.fallback.incrBy(key, n, ttl) - return v, true -} - -// failPolicyGet mirrors failPolicyIncrBy for a read. +// failPolicyGet is failPolicyGetMulti's single-key special case, isolating +// the fail-open/fail-closed branch itself (TestLimiter_FailPolicyGet) from +// storeGet's own error/latched paths that already drive it indirectly. func (l *limiter) failPolicyGet(key string) (int64, bool) { - if !l.failOpen { + v, ok := l.failPolicyGetMulti([]string{key}) + if !ok || len(v) == 0 { return 0, false } - v, _ := l.fallback.get(key) - return v, true + return v[0], true } -// incrCounter increments the (kind, id, metric, window) counter at t by n -// and returns its new value, together with whether the operation -// succeeded under the limiter's fail-open/fail-closed policy — the -// limiter-level single-key seeding helper tests use to seed counters -// directly (admin_test.go's TestAdminUsage_MathAgainstSeededCounters and -// others), implemented via the batched storeIncrMulti path. -func (l *limiter) incrCounter(kind, id, metric, window string, t time.Time, n int64, ttl time.Duration) (int64, bool) { - return l.storeIncrBy(windowKey(kind, id, metric, window, t), n, ttl) +// failPolicyIncrBy mirrors failPolicyGet for failPolicyIncrMulti +// (TestLimiter_FailPolicyIncrBy). +func (l *limiter) failPolicyIncrBy(key string, delta int64, ttl time.Duration) (int64, bool) { + v, ok := l.failPolicyIncrMulti([]counterIncr{{key: key, delta: delta, ttl: ttl}}) + if !ok || len(v) == 0 { + return 0, false + } + return v[0], true } -// getCounter returns the (kind, id, metric, window) counter's value at t, -// together with whether the read succeeded under the limiter's fail-open/ -// fail-closed policy, implemented via the batched storeGetMulti path. -func (l *limiter) getCounter(kind, id, metric, window string, t time.Time) (int64, bool) { - return l.storeGet(windowKey(kind, id, metric, window, t)) +// storeIncrBy is storeIncrMulti's single-entry special case: build one +// counterIncr{key, delta, ttl} (enforceTTL left at its zero value — +// "ceiling only, no floor", clampTTL's own doc comment) and apply the +// identical fail-open/fail-closed/latched policy storeIncrMulti already +// applies to a batch. No production caller needs a single-key increment +// on its own (checkAndCount/accountWith/countTargetRequestsBy/ +// recordProviderAttempt all batch); this exists purely so +// TestLimiter_StoreIncrBy_DirectPaths and incrCounter (below) can drive +// and seed one counter at a time without building a one-element slice +// literal at every call site. +func (l *limiter) storeIncrBy(key string, delta int64, ttl time.Duration) (int64, bool) { + v, ok := l.storeIncrMulti([]counterIncr{{key: key, delta: delta, ttl: ttl}}) + if !ok || len(v) == 0 { + return 0, false + } + return v[0], true +} + +// storeGet is storeGetMulti's single-key special case, mirroring +// storeIncrBy for the read side — TestLimiter_StoreGet_DirectPaths' own +// subject. +func (l *limiter) storeGet(key string) (int64, bool) { + v, ok := l.storeGetMulti([]string{key}) + if !ok || len(v) == 0 { + return 0, false + } + return v[0], true +} + +// incrCounter seeds exactly one (kind, id, metric, window) counter's +// bucket for the instant now falls in, incrementing it by n with TTL +// ttl — windowKey (limits.go) plus a single storeIncrBy call. This is +// every test file's own direct counter-seeding primitive (admin_test.go, +// limits_test.go, metrics_test.go): production code never seeds a +// counter outside of a real accountWith/checkAndCount/ +// countTargetRequestsBy/recordProviderAttempt call, so there is nothing +// for this to mirror there — it exists purely to put a known value at a +// known key without a test reaching past the limiter into windowKey and +// storeIncrBy itself at every call site. The store-failure return +// storeIncrBy carries is intentionally discarded: every caller seeds +// against a nil-store or fixedStore limiter where the write cannot fail, +// and a seeding helper silently doing nothing on an unexpected failure +// would only surface as a confusing assertion mismatch two lines later, +// not here. +func (l *limiter) incrCounter(kind, id, metric, window string, now time.Time, n int64, ttl time.Duration) { + key := windowKey(kind, id, metric, window, now) + _, _ = l.storeIncrBy(key, n, ttl) +} + +// getCounter reads exactly one (kind, id, metric, window) counter's +// bucket for the instant now falls in — windowKey (limits.go) plus a +// single storeGet call, incrCounter's read-side mirror and this whole +// test suite's dominant assertion primitive: "did the right counter end +// up with the right value after this request". ok is false only in the +// fail-closed case, exactly like storeGet/storeGetMulti's own contract; +// every real assertion above passes ok through require.True/assert +// rather than ignoring it, the same discipline production callers +// already apply to storeGetMulti's own ok. +func (l *limiter) getCounter(kind, id, metric, window string, now time.Time) (int64, bool) { + key := windowKey(kind, id, metric, window, now) + return l.storeGet(key) } diff --git a/limits_test.go b/limits_test.go index 236a2f3..38e5dd5 100644 --- a/limits_test.go +++ b/limits_test.go @@ -2792,3 +2792,818 @@ func TestTargetUsage_ConfiguredStoreDown_FailOpen_ReturnsZeroCounters(t *testing t.Errorf("got[0] = %+v, want the zero value", got[0]) } } + +// --- admin-redesign WP-A (steps 1-8) --- + +// TestMemoryStore_ApplySet_OverwritesRatherThanAdds proves memoryStore's +// absolute-entry path (last-seen SET, Q6 DECISIONS) always REPLACES a +// key's value, never adds to it — the SET-vs-INCRBY distinction +// counterIncr.absolute exists to carry. +func TestMemoryStore_ApplySet_OverwritesRatherThanAdds(t *testing.T) { + m := newMemoryStore() + m.nowFn = func() time.Time { return time.Unix(1000, 0) } + + if got := m.applySet("k", 42, time.Minute); got != 42 { + t.Fatalf("first applySet = %d, want 42", got) + } + if got := m.applySet("k", 7, time.Minute); got != 7 { + t.Errorf("second applySet = %d, want 7 (overwrite, not 49)", got) + } + if v, err := m.get("k"); err != nil || v != 7 { + t.Errorf("get(k) = %d, err=%v, want 7, nil", v, err) + } +} + +// TestMemoryStore_IncrMulti_AbsoluteEntry_DispatchesToApplySet proves +// incrMulti routes an absolute counterIncr through applySet (overwrite), +// never applyIncr (add) — a non-absolute entry in the same batch still +// increments normally. +func TestMemoryStore_IncrMulti_AbsoluteEntry_DispatchesToApplySet(t *testing.T) { + m := newMemoryStore() + m.nowFn = func() time.Time { return time.Unix(2000, 0) } + + out, err := m.incrMulti([]counterIncr{ + {key: "seen", delta: 555, ttl: time.Hour, absolute: true}, + {key: "count", delta: 3, ttl: time.Hour}, + }) + if err != nil { + t.Fatalf("incrMulti: %v", err) + } + if out[0] != 555 { + t.Errorf("absolute entry result = %d, want 555", out[0]) + } + if out[1] != 3 { + t.Errorf("incr entry result = %d, want 3", out[1]) + } + + out2, _ := m.incrMulti([]counterIncr{{key: "count", delta: 3, ttl: time.Hour}}) + if out2[0] != 6 { + t.Errorf("second incr on non-absolute key = %d, want 6 (added)", out2[0]) + } + out3, _ := m.incrMulti([]counterIncr{{key: "seen", delta: 999, ttl: time.Hour, absolute: true}}) + if out3[0] != 999 { + t.Errorf("second absolute write = %d, want 999 (overwrite, not 555+999)", out3[0]) + } +} + +// TestLastSeenGate_ThrottlesWithin60Seconds is Q6's own throttle contract: +// two due() calls for the same scope inside lastSeenGateThrottle yield +// exactly one true; a different scope is never throttled by another +// scope's own state. +func TestLastSeenGate_ThrottlesWithin60Seconds(t *testing.T) { + g := &lastSeenGate{} + t0 := time.Unix(10_000, 0) + + if !g.due("user", "alice", t0) { + t.Fatal("first due() = false, want true") + } + if g.due("user", "alice", t0.Add(30*time.Second)) { + t.Error("due() 30s later = true, want false (within throttle)") + } + if !g.due("user", "alice", t0.Add(61*time.Second)) { + t.Error("due() 61s later = false, want true (throttle elapsed)") + } + if !g.due("user", "bob", t0.Add(30*time.Second)) { + t.Error("due() for a different scope = false, want true") + } +} + +// TestLastSeenGate_ThrottlesPastMapCap is P8 (admin dashboard redesign +// verify round): due()'s two-map rotation wrote a scope forward into +// `previous` once `current` filled past lastSeenGateMapCapBase, but +// nothing ever CONSULTED previous on lookup — every scope that had been +// rotated out was treated as brand new on its very next call, so +// throttling silently stopped working once more than +// lastSeenGateMapCapBase (4,096) distinct scopes were active. 5,000 +// distinct scopes, each looked up twice within the same second (round 2 +// repeats round 1's exact scopes, same instant), must produce exactly +// 5,000 due()=true calls total — the pre-fix code produced far more, +// since round 2 mostly missed both the (rotated) previous map's own +// throttle and its freshly-empty current map. 5,000 stays below the N5 +// adaptive-growth threshold (it only crosses ONE rotation, and growth +// only triggers on a SECOND rotation within lastSeenGateThrottle — see +// TestLastSeenGate_AdaptiveCapacityHoldsPastEightThousandScopes for the +// scope count that exercises growth itself), so this keeps proving the +// original fixed-cap rotation path unchanged. +func TestLastSeenGate_ThrottlesPastMapCap(t *testing.T) { + g := &lastSeenGate{} + now := time.Unix(1_800_000_000, 0) + const scopeCount = 5000 + if scopeCount <= lastSeenGateMapCapBase { + t.Fatalf("scopeCount (%d) must exceed lastSeenGateMapCapBase (%d) to exercise rotation", scopeCount, lastSeenGateMapCapBase) + } + writes := 0 + for round := 0; round < 2; round++ { + for i := 0; i < scopeCount; i++ { + if g.due("user", fmt.Sprintf("u%d", i), now) { + writes++ + } + } + } + if writes != scopeCount { + t.Errorf("due()=true across 2 rounds of %d scopes within the same second = %d, want %d (round 2 must be fully throttled)", scopeCount, writes, scopeCount) + } +} + +// TestLastSeenGate_AdaptiveCapacityHoldsPastEightThousandScopes is N5 +// (verify-redesign-final.md): a fixed 4,096-entry cap only throttled +// correctly up to ~2x itself (current+previous both full, ~8,192 live +// scopes) — beyond that, current rotated out before lastSeenGateThrottle +// could elapse for most of what it held, discarding still-fresh writes +// on every rotation, which degraded round 2 of a repeat to one write per +// admission instead of zero. 20,000 distinct scopes (well past the old +// ~8,192 ceiling), each looked up twice within the same second, must +// still produce close to 20,000 due()=true calls total (round 1 only; +// round 2 must be (almost) fully throttled) — proving due()'s adaptive +// capacity (grows on a same-window second rotation, capped at +// lastSeenGateMapCapMax) keeps the whole live population inside +// current+previous instead of thrashing it. +func TestLastSeenGate_AdaptiveCapacityHoldsPastEightThousandScopes(t *testing.T) { + g := &lastSeenGate{} + now := time.Unix(1_800_000_000, 0) + const scopeCount = 20000 + if scopeCount <= 2*lastSeenGateMapCapBase { + t.Fatalf("scopeCount (%d) must exceed the old 2x-cap ceiling (%d) to exercise adaptive growth", scopeCount, 2*lastSeenGateMapCapBase) + } + writes := 0 + for round := 0; round < 2; round++ { + for i := 0; i < scopeCount; i++ { + if g.due("user", fmt.Sprintf("u%d", i), now) { + writes++ + } + } + } + // "~20000": round 1 always writes scopeCount times (every scope is + // new). Round 2 may add a handful more only if a scope's write fell + // right before the growth threshold was reached, never a large + // fraction of scopeCount — the old, pre-fix behaviour wrote roughly + // scopeCount MORE on round 2 alone (P8's "far more" regression, at + // this population size), so a generous 1% slack still tells the two + // behaviours apart with room to spare. + maxWant := scopeCount + scopeCount/100 + if writes < scopeCount || writes > maxWant { + t.Errorf("due()=true across 2 rounds of %d scopes within the same second = %d, want in [%d, %d]", scopeCount, writes, scopeCount, maxWant) + } + if g.capacity > lastSeenGateMapCapMax { + t.Errorf("g.capacity = %d, want <= lastSeenGateMapCapMax (%d)", g.capacity, lastSeenGateMapCapMax) + } + total := len(g.current) + len(g.previous) + if bound := 2 * lastSeenGateMapCapMax; total > bound { + t.Errorf("len(current)+len(previous) = %d, want <= 2*lastSeenGateMapCapMax (%d) — memory bound violated", total, bound) + } +} + +// TestCheckAndCount_NeverWritesLastSeen is the P9-fix replacement for this +// test's own pre-fix name (AppendedAfterScopeStride): last-seen no longer +// rides checkAndCount's batch AT ALL (admin dashboard redesign verify +// round) — it used to be appended after every scope's own three stride +// entries unconditionally, which meant a REJECTED request still moved a +// scope's last-seen timestamp forward, since checkAndCount's whole batch, +// last-seen tail included, was already sent to the store before the +// violation check even ran. checkAndCount's own entry count is now +// EXACTLY 3*checkAndCountKeysPerScope regardless of l.statsAdmin — see +// TestRecordLastSeen_WritesTailEntriesForUserAndGroupScopesOnly, below, +// for the tail-entry coverage this test used to carry, now exercised +// against recordLastSeen directly (the function real admission call sites +// invoke instead, only once admission is confirmed). +func TestCheckAndCount_NeverWritesLastSeen(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + now := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + l.nowFn = func() time.Time { return now } + l.statsAdmin = true + + scopes := []limitScope{ + {kind: "user", id: "alice", limits: &LimitsConfig{RequestsPerMinute: 1000}}, + {kind: "group", id: "friends", limits: &LimitsConfig{RequestsPerMinute: 1000}}, + {kind: totalScopeKind, id: totalScopeID}, + } + if v := l.checkAndCount(scopes); v != nil { + t.Fatalf("checkAndCount violation = %+v, want nil", v) + } + + if len(store.entries) != 3*checkAndCountKeysPerScope { + t.Fatalf("len(store.entries) = %d, want %d (no last-seen tail)", len(store.entries), 3*checkAndCountKeysPerScope) + } + for i, e := range store.entries { + if e.absolute { + t.Errorf("entries[%d] (%q) is absolute, want checkAndCount to never write an absolute entry any more", i, e.key) + } + } +} + +// TestRecordLastSeen_WritesTailEntriesForUserAndGroupScopesOnly proves +// recordLastSeen (limits.go; called by every admission call site's own +// "violation == nil" branch, P9 fix) writes exactly one absolute SET per +// "user"/"group" scope, keyed by lastSeenKey, and never one for the +// synthetic total scope. +func TestRecordLastSeen_WritesTailEntriesForUserAndGroupScopesOnly(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + now := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + l.nowFn = func() time.Time { return now } + l.statsAdmin = true + l.spawn = func(f func()) { f() } // synchronous for the test to observe entries + + scopes := []limitScope{ + {kind: "user", id: "alice"}, + {kind: "group", id: "friends"}, + {kind: totalScopeKind, id: totalScopeID}, + } + l.recordLastSeen(scopes, now) + + if len(store.entries) != 2 { + t.Fatalf("len(store.entries) = %d, want 2 (user + group, never total)", len(store.entries)) + } + seenKeys := map[string]bool{} + for _, e := range store.entries { + if !e.absolute { + t.Errorf("entry %q is not absolute", e.key) + } + seenKeys[e.key] = true + } + if !seenKeys[lastSeenKey("user", "alice")] || !seenKeys[lastSeenKey("group", "friends")] { + t.Errorf("entries = %v, want lastSeenKey for user alice and group friends", store.entries) + } + if seenKeys[lastSeenKey(totalScopeKind, totalScopeID)] { + t.Error("total scope must never get a last-seen entry") + } +} + +// TestRecordLastSeen_OffWithoutStatsAdmin proves recordLastSeen (P9 fix's +// own call site, invoked separately from checkAndCount now) builds zero +// entries and spawns nothing when statsAdmin is false (the default for +// every limiter built directly via newLimiter, unless a test opts in) — +// the same "additive and default-preserving" guarantee every other family +// in this task carries. spawn is deliberately left at its real (async, +// goroutine-based) default: if this ever regressed to spawning an empty +// batch, len(store.entries) staying 0 synchronously would not catch it, +// so failing to compile a nil/empty entries slice at all (countAsync's own +// "nil/empty entries slice spawns nothing" contract, limits.go) is what +// this test actually pins. +func TestRecordLastSeen_OffWithoutStatsAdmin(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.recordLastSeen([]limitScope{{kind: "user", id: "alice"}}, l.now()) + if len(store.entries) != 0 { + t.Errorf("entries = %d, want 0 (no last-seen without statsAdmin)", len(store.entries)) + } +} + +// TestCheckAndCount_LastSeen_OffWithoutStatsAdmin proves checkAndCount +// itself never writes a last-seen entry, with or without statsAdmin — P9 +// fix, admin dashboard redesign verify round: last-seen no longer rides +// checkAndCount's batch at all (TestCheckAndCount_NeverWritesLastSeen, +// above, pins the statsAdmin=true case; this pins the default-off case +// the pre-fix name of this test originally covered). +func TestCheckAndCount_LastSeen_OffWithoutStatsAdmin(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.checkAndCount([]limitScope{{kind: "user", id: "alice"}}) + if len(store.entries) != checkAndCountKeysPerScope { + t.Errorf("entries = %d, want %d (checkAndCount never writes last-seen any more)", len(store.entries), checkAndCountKeysPerScope) + } +} + +// TestRecordLastSeen_ThrottledSecondCallWithinWindowSkipsEntry proves the +// SAME replica calling recordLastSeen twice in immediate succession (the +// P9-fix call site, admin dashboard redesign verify round — every +// admission call site invokes it once per admitted request) writes the +// last-seen SET only once, via lastSeenGate.due's own throttle. +func TestRecordLastSeen_ThrottledSecondCallWithinWindowSkipsEntry(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + now := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + l.nowFn = func() time.Time { return now } + l.statsAdmin = true + l.spawn = func(f func()) { f() } + scopes := []limitScope{{kind: "user", id: "alice"}} + + l.recordLastSeen(scopes, now) + if len(store.entries) != 1 { + t.Fatalf("first call entries = %d, want 1", len(store.entries)) + } + store.entries = nil + l.recordLastSeen(scopes, now) + if len(store.entries) != 0 { + t.Errorf("second call (same instant) entries = %d, want 0 (throttled, no last-seen entry)", len(store.entries)) + } +} + +// TestSettleRejection_RejHour_OnlyWhenStatsAdmin proves settleRejection's +// rej:hour addition (rej/hour, always-on-with-admin) rides the same +// compensation batch only when statsAdmin is true, alongside the +// pre-existing, unconditional rej:day entry — and that the compensation +// entries for OTHER scopes (the rollback half) are unaffected either way. +func TestSettleRejection_RejHour_OnlyWhenStatsAdmin(t *testing.T) { + now := time.Date(2026, 9, 23, 10, 30, 0, 0, time.UTC) + wb := bucketsFor(now) + et := enforceTTLsFor() + scopes := []limitScope{ + {kind: "user", id: "alice"}, + {kind: totalScopeKind, id: totalScopeID}, + } + entries := []counterIncr{ + {key: windowKeyForBucket("user", "alice", metricReq, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min}, + {key: windowKeyForBucket("user", "alice", metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + {key: windowKeyForBucket("user", "alice", metricReq, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + {key: windowKeyForBucket(totalScopeKind, totalScopeID, metricReq, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min}, + {key: windowKeyForBucket(totalScopeKind, totalScopeID, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day}, + {key: windowKeyForBucket(totalScopeKind, totalScopeID, metricReq, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour}, + } + + for _, statsAdmin := range []bool{false, true} { + store := &recordingStore{} + l := newLimiter(store, true) + l.nowFn = func() time.Time { return now } + l.statsAdmin = statsAdmin + l.settleRejection(entries, 0, scopes, wb, et) + + rejHourKey := windowKeyForBucket("user", "alice", metricRej, windowHour, wb.hour) + var gotHour bool + for _, e := range store.entries { + if e.key == rejHourKey { + gotHour = true + } + } + if gotHour != statsAdmin { + t.Errorf("statsAdmin=%v: rej:hour entry present=%v, want %v", statsAdmin, gotHour, statsAdmin) + } + } +} + +// TestAccountWith_UserModel_RequiresOneUserScopeAndModelScope is (a), +// plan §2: the opt-in per-(user, model) breakdown fires only when +// statsUserModel is on, exactly one "user" scope is present, and a +// kindModel scope is present — writing <=8 entries (req always, +// tokin/tokout/cost only when nonzero) at day+month, never hour. +func TestAccountWith_UserModel_RequiresOneUserScopeAndModelScope(t *testing.T) { + id := userModelScopeID("alice", "openai/gpt-4o") + + cases := []struct { + name string + scopes []limitScope + statsUserModel bool + wantEntries bool + }{ + {name: "on, one user scope, model scope", statsUserModel: true, wantEntries: true, + scopes: withModelScope([]limitScope{{kind: "user", id: "alice"}}, "openai/gpt-4o")}, + {name: "flag off", statsUserModel: false, wantEntries: false, + scopes: withModelScope([]limitScope{{kind: "user", id: "alice"}}, "openai/gpt-4o")}, + {name: "no model scope", statsUserModel: true, wantEntries: false, + scopes: []limitScope{{kind: "user", id: "alice"}}}, + {name: "multi-group principal: two user-kind scopes never happens, but two USER scopes must still refuse", + statsUserModel: true, wantEntries: false, + scopes: withModelScope([]limitScope{{kind: "user", id: "alice"}, {kind: "user", id: "bob"}}, "openai/gpt-4o")}, + {name: "media: no user scope at all", statsUserModel: true, wantEntries: false, + scopes: withModelScope(nil, "openai/gpt-4o")}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.statsAdmin = true + l.statsUserModel = c.statsUserModel + l.accountWith(c.scopes, usage{prompt: 10, completion: 5}, 1000, accountExtras{}) + + var got int + for _, e := range store.entries { + if strings.HasPrefix(e.key, "llmgw:"+kindUserModel+":"+id+":") { + got++ + } + } + if c.wantEntries && got != 8 { + t.Errorf("umodel entries = %d, want 8", got) + } + if !c.wantEntries && got != 0 { + t.Errorf("umodel entries = %d, want 0", got) + } + }) + } +} + +// TestAccountWith_UserModel_ReqAlwaysEvenWithZeroUsage proves the req pair +// is written unconditionally (mirroring kindModel's own unconditional req +// write), even for a served request with zero reported usage. +func TestAccountWith_UserModel_ReqAlwaysEvenWithZeroUsage(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.statsAdmin = true + l.statsUserModel = true + scopes := withModelScope([]limitScope{{kind: "user", id: "alice"}}, "openai/gpt-4o") + l.accountWith(scopes, usage{}, 0, accountExtras{}) + + id := userModelScopeID("alice", "openai/gpt-4o") + var got int + for _, e := range store.entries { + if strings.HasPrefix(e.key, "llmgw:"+kindUserModel+":"+id+":") && strings.Contains(e.key, ":"+metricReq+":") { + got++ + } + } + if got != 2 { + t.Errorf("umodel req entries = %d, want 2 (day+month)", got) + } +} + +// TestAccountWith_Failover_IncrementsFromProvider is (c), plan §2: a +// non-empty accountExtras.failoverFrom writes prov/{from}:fover at +// hour+day when statsAdmin is on, never when it is off. +func TestAccountWith_Failover_IncrementsFromProvider(t *testing.T) { + for _, statsAdmin := range []bool{false, true} { + store := &recordingStore{} + l := newLimiter(store, true) + l.statsAdmin = statsAdmin + l.accountWith(nil, usage{}, 0, accountExtras{failoverFrom: "openai"}) + + var got int + for _, e := range store.entries { + if strings.HasPrefix(e.key, "llmgw:"+kindProvider+":openai:"+metricFover+":") { + got++ + } + } + want := 0 + if statsAdmin { + want = 2 + } + if got != want { + t.Errorf("statsAdmin=%v: fover entries = %d, want %d", statsAdmin, got, want) + } + } +} + +// TestLatencyBucketIndex_MatchesObserveLatencyBucket proves accountWith's +// own bucket-index helper (limits.go) never disagrees with +// observeLatencyBucket's in-process histogram (metrics.go) about which +// bucket a duration belongs to — both must derive from the identical +// latencyBucketIndex. +func TestLatencyBucketIndex_MatchesObserveLatencyBucket(t *testing.T) { + durations := []time.Duration{ + 0, 50 * time.Millisecond, 100 * time.Millisecond, 999 * time.Millisecond, + 2 * time.Second, 30 * time.Second, 600 * time.Second, 3600 * time.Second, + } + for _, d := range durations { + idx, overflow := latencyBucketIndex(d) + + var buckets [1]int64 + full := make([]int64, len(latencyBucketBounds)) + var overflowCount int64 + var sum float64 + var count int64 + observeLatencyBucket(full, &overflowCount, &sum, &count, d) + _ = buckets + + if overflow { + if overflowCount != 1 { + t.Errorf("d=%v: latencyBucketIndex says overflow, observeLatencyBucket overflow count = %d, want 1", d, overflowCount) + } + if idx != len(latencyBucketBounds) { + t.Errorf("d=%v: overflow idx = %d, want %d", d, idx, len(latencyBucketBounds)) + } + continue + } + if full[idx] != 1 { + t.Errorf("d=%v: latencyBucketIndex says bucket %d, observeLatencyBucket did not increment it: buckets=%v", d, idx, full) + } + } +} + +// TestAccountWith_Latency_WritesProviderAndModelBuckets is (c), plan §2: +// hasLat && statsLatency writes the serving provider's own duration +// bucket (hour+day) plus the served model's own (when a kindModel scope +// is present), and TTFB buckets too when the sample carries one — riding +// the SAME storeIncrMulti batch account already builds (never a second +// round trip: this test's own recordingStore records exactly one +// incrMulti call for the whole accountWith invocation). +func TestAccountWith_Latency_WritesProviderAndModelBuckets(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.statsAdmin = true + l.statsLatency = true + scopes := withModelScope(nil, "openai/gpt-4o") + lat := latencySample{duration: 2 * time.Second, ttfb: 100 * time.Millisecond, hasTTFB: true} + l.accountWith(scopes, usage{}, 0, accountExtras{provider: "openai", lat: lat, hasLat: true}) + + durIdx, _ := latencyBucketIndex(lat.duration) + ttfbIdx, _ := latencyBucketIndex(lat.ttfb) + wantDur := latencyDurationMetric(durIdx) + wantTTFB := latencyTTFBMetric(ttfbIdx) + + counts := map[string]int{} + for _, e := range store.entries { + counts[e.key]++ + } + for _, kind := range []string{kindProvider, kindModel} { + id := "openai" + if kind == kindModel { + id = "openai/gpt-4o" + } + for _, window := range []string{windowHour, windowDay} { + for _, metric := range []string{wantDur, wantTTFB} { + key := windowKeyForBucket(kind, id, metric, window, "x") + // windowKeyForBucket's own bucket arg is fixed to "x" here + // only to build a comparable prefix; match by prefix minus + // bucket instead of exact key. + prefix := key[:len(key)-1] + var found bool + for k := range counts { + if strings.HasPrefix(k, prefix) { + found = true + } + } + if !found { + t.Errorf("missing latency entry for kind=%s id=%s metric=%s window=%s", kind, id, metric, window) + } + } + } + } +} + +// TestAccountWith_CacheMiss_TotalHourDayAndModelDayOnly is (d), plan §2: +// cacheMiss writes total cmiss at hour+day, and the served model's own +// cmiss at day ONLY (never hour) — gated on statsAdmin. +func TestAccountWith_CacheMiss_TotalHourDayAndModelDayOnly(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.statsAdmin = true + scopes := withModelScope([]limitScope{{kind: totalScopeKind, id: totalScopeID}}, "openai/gpt-4o") + l.accountWith(scopes, usage{}, 0, accountExtras{cacheMiss: true}) + + totalHour := windowKeyForBucket(totalScopeKind, totalScopeID, metricCmiss, windowHour, bucketFor(l.now(), windowHour)) + totalDay := windowKeyForBucket(totalScopeKind, totalScopeID, metricCmiss, windowDay, bucketFor(l.now(), windowDay)) + modelDay := windowKeyForBucket(kindModel, "openai/gpt-4o", metricCmiss, windowDay, bucketFor(l.now(), windowDay)) + modelHour := windowKeyForBucket(kindModel, "openai/gpt-4o", metricCmiss, windowHour, bucketFor(l.now(), windowHour)) + + present := map[string]bool{} + for _, e := range store.entries { + present[e.key] = true + } + if !present[totalHour] || !present[totalDay] { + t.Errorf("missing total cmiss hour/day entries: %v", store.entries) + } + if !present[modelDay] { + t.Error("missing model cmiss day entry") + } + if present[modelHour] { + t.Error("model cmiss hour entry present, want day only") + } +} + +// TestRecordCacheHit_ChitAlwaysCsaveSkippedWhenZero is recordCacheHit's +// own contract (Q7, DECISIONS): total chit hour+day always, csave +// hour+day only when savedMicros != 0, model chit/csave at day only. +func TestRecordCacheHit_ChitAlwaysCsaveSkippedWhenZero(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.statsAdmin = true + l.spawn = func(f func()) { f() } // synchronous for the test to observe entries + + l.recordCacheHit("openai/gpt-4o", 0) + + var chit, csave int + for _, e := range store.entries { + switch { + case strings.Contains(e.key, ":"+metricChit+":"): + chit++ + case strings.Contains(e.key, ":"+metricCsave+":"): + csave++ + } + } + if chit != 3 { // total hour, total day, model day + t.Errorf("chit entries = %d, want 3", chit) + } + if csave != 0 { + t.Errorf("csave entries = %d, want 0 (saved=0)", csave) + } + + store.entries = nil + l.recordCacheHit("openai/gpt-4o", 500) + csave = 0 + for _, e := range store.entries { + if strings.Contains(e.key, ":"+metricCsave+":") { + csave++ + } + } + if csave != 3 { // total hour, total day, model day + t.Errorf("csave entries (saved=500) = %d, want 3", csave) + } +} + +// TestRecordCacheHit_OffWithoutStatsAdmin proves recordCacheHit spawns +// nothing at all when admin stats are off. +func TestRecordCacheHit_OffWithoutStatsAdmin(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.spawn = func(f func()) { f() } + l.recordCacheHit("openai/gpt-4o", 500) + if len(store.entries) != 0 { + t.Errorf("entries = %d, want 0 (statsAdmin off)", len(store.entries)) + } +} + +// TestRecordUnpriced402_TotalAndModelHourDay proves recordUnpriced402 +// writes total r402 hour+day plus the refused model's own r402 hour+day, +// gated on statsAdmin — the counter events.go's recordUnpricedRefusalEvent +// drives from its own single chokepoint (both routes_unified.go's and +// routes_passthrough.go's identical 402 guard). +func TestRecordUnpriced402_TotalAndModelHourDay(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.statsAdmin = true + l.spawn = func(f func()) { f() } + + l.recordUnpriced402("openai/free-model") + + present := map[string]bool{} + for _, e := range store.entries { + present[e.key] = true + } + now := l.now() + wantKeys := []string{ + windowKeyForBucket(totalScopeKind, totalScopeID, metricR402, windowHour, bucketFor(now, windowHour)), + windowKeyForBucket(totalScopeKind, totalScopeID, metricR402, windowDay, bucketFor(now, windowDay)), + windowKeyForBucket(kindModel, "openai/free-model", metricR402, windowHour, bucketFor(now, windowHour)), + windowKeyForBucket(kindModel, "openai/free-model", metricR402, windowDay, bucketFor(now, windowDay)), + } + for _, k := range wantKeys { + if !present[k] { + t.Errorf("missing r402 entry %q", k) + } + } + if len(store.entries) != 4 { + t.Errorf("total entries = %d, want 4", len(store.entries)) + } +} + +// TestRecordProviderAttempt_HourlyAndTimeout_OnlyWithStatsAdmin proves +// step 3: prov/provmodel attempt+fail at HOUR granularity, and prov's own +// timeout hour+day, are added only when statsAdmin is on — riding +// recordProviderAttempt's SAME async batch (one spawn call either way, +// never a second round trip). +func TestRecordProviderAttempt_HourlyAndTimeout_OnlyWithStatsAdmin(t *testing.T) { + for _, statsAdmin := range []bool{false, true} { + store := &recordingStore{} + l := newLimiter(store, true) + l.spawn = func(f func()) { f() } + l.statsAdmin = statsAdmin + + l.recordProviderAttempt("openai", "gpt-4o", nil, errProviderTimeout) + + var hourAttempt, hourFail, hourTimeout, dayTimeout, modelHourAttempt int + for _, e := range store.entries { + switch { + case e.key == windowKeyForBucket(kindProvider, "openai", metricProvAttempt, windowHour, bucketFor(l.now(), windowHour)): + hourAttempt++ + case e.key == windowKeyForBucket(kindProvider, "openai", metricProvFail, windowHour, bucketFor(l.now(), windowHour)): + hourFail++ + case e.key == windowKeyForBucket(kindProvider, "openai", metricProvTimeout, windowHour, bucketFor(l.now(), windowHour)): + hourTimeout++ + case e.key == windowKeyForBucket(kindProvider, "openai", metricProvTimeout, windowDay, bucketFor(l.now(), windowDay)): + dayTimeout++ + case e.key == windowKeyForBucket(kindProviderModel, providerModelScopeID("openai", "gpt-4o"), metricProvAttempt, windowHour, bucketFor(l.now(), windowHour)): + modelHourAttempt++ + } + } + want := 0 + if statsAdmin { + want = 1 + } + if hourAttempt != want || hourFail != want || hourTimeout != want || dayTimeout != want || modelHourAttempt != want { + t.Errorf("statsAdmin=%v: hourAttempt=%d hourFail=%d hourTimeout=%d dayTimeout=%d modelHourAttempt=%d, want %d each", + statsAdmin, hourAttempt, hourFail, hourTimeout, dayTimeout, modelHourAttempt, want) + } + } +} + +// TestCountTargetRequestsBy_CallerID_OnlyWithStatsAdmin proves step 8: +// a non-empty caller writes kindTargetCaller req entries at day+month +// (targetCallerScopeID("mcp", target, caller)) only when statsAdmin is +// on, in the SAME batch countTargetRequests already builds. +func TestCountTargetRequestsBy_CallerID_OnlyWithStatsAdmin(t *testing.T) { + for _, statsAdmin := range []bool{false, true} { + store := &recordingStore{} + l := newLimiter(store, true) + l.statsAdmin = statsAdmin + l.countTargetRequestBy("alice", targetKindMCP, "fetch") + + id := targetCallerScopeID(targetKindMCP, "fetch", "alice") + var got int + for _, e := range store.entries { + if strings.HasPrefix(e.key, "llmgw:"+kindTargetCaller+":"+id+":") { + got++ + } + } + want := 0 + if statsAdmin { + want = 2 // day + month + } + if got != want { + t.Errorf("statsAdmin=%v: tcaller entries = %d, want %d", statsAdmin, got, want) + } + } +} + +// TestCountTargetRequestsBy_EmptyCaller_MatchesCountTargetRequests proves +// countTargetRequests (no caller) is byte-for-byte countTargetRequestsBy's +// own caller="" special case — no behavior change for any pre-existing +// caller of the old name. +func TestCountTargetRequestsBy_EmptyCaller_MatchesCountTargetRequests(t *testing.T) { + scopes := []limitScope{{kind: targetKindMCP, id: "fetch"}} + + storeA := &recordingStore{} + lA := newLimiter(storeA, true) + lA.statsAdmin = true + lA.nowFn = func() time.Time { return time.Unix(0, 0) } + lA.countTargetRequests(scopes) + + storeB := &recordingStore{} + lB := newLimiter(storeB, true) + lB.statsAdmin = true + lB.nowFn = func() time.Time { return time.Unix(0, 0) } + lB.countTargetRequestsBy("", scopes) + + if len(storeA.entries) != len(storeB.entries) { + t.Fatalf("len(A)=%d len(B)=%d, want equal", len(storeA.entries), len(storeB.entries)) + } + for i := range storeA.entries { + if storeA.entries[i].key != storeB.entries[i].key || storeA.entries[i].delta != storeB.entries[i].delta { + t.Errorf("entry %d differs: A=%+v B=%+v", i, storeA.entries[i], storeB.entries[i]) + } + } +} + +// TestHistoryBucketKeysAt_OffsetZero_MatchesHistoryBucketKeys proves +// offset=0 is byte-for-byte historyBucketKeys' own pre-existing output. +func TestHistoryBucketKeysAt_OffsetZero_MatchesHistoryBucketKeys(t *testing.T) { + now := time.Date(2026, 9, 23, 10, 30, 0, 0, time.UTC) + wantKeys, wantBuckets := historyBucketKeys(kindModel, "openai/gpt-4o", metricReq, windowDay, now, 5) + gotKeys, gotBuckets := historyBucketKeysAt(kindModel, "openai/gpt-4o", metricReq, windowDay, now, 5, 0) + assert.Equal(t, wantKeys, gotKeys) + assert.Equal(t, wantBuckets, gotBuckets) +} + +// TestHistoryBucketKeysAt_Offset_ShiftsWholeSpanBack proves an offset +// shifts the ENTIRE span back that many window units — the span=3, +// offset=2 window ends exactly 2 days before the span=3, offset=0 +// window's own oldest bucket. +func TestHistoryBucketKeysAt_Offset_ShiftsWholeSpanBack(t *testing.T) { + now := time.Date(2026, 9, 23, 10, 30, 0, 0, time.UTC) + _, baseBuckets := historyBucketKeysAt(kindModel, "m", metricReq, windowDay, now, 3, 0) + _, offsetBuckets := historyBucketKeysAt(kindModel, "m", metricReq, windowDay, now, 3, 2) + + // offsetBuckets' newest bucket must equal baseBuckets' oldest bucket + // shifted back one more day (span=3 starting 2 days earlier ends 2 + // days earlier too). + wantNewest := bucketFor(historyStepBack(now, windowDay, 2), windowDay) + if offsetBuckets[len(offsetBuckets)-1] != wantNewest { + t.Errorf("offset span's newest bucket = %s, want %s", offsetBuckets[len(offsetBuckets)-1], wantNewest) + } + if offsetBuckets[0] == baseBuckets[0] { + t.Error("offset span's oldest bucket must differ from the unshifted span's") + } +} + +// TestSpanTotalsMulti_Offset_ReadsAnEarlierSpan proves spanTotalsMulti's +// offset argument reads a DIFFERENT (earlier) set of buckets than +// offset=0 (plan §1.3(iii)) — exercised directly against the PRODUCTION +// reader (limits.go), the same one admin.go's chunkedModelSpanTotals/ +// chunkedModelSpanTotalsMulti and stats_read.go's readSpanTotals now all +// share (P12 fix, admin dashboard redesign verify round). +func TestSpanTotalsMulti_Offset_ReadsAnEarlierSpan(t *testing.T) { + l := newLimiter(nil, true) + now := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC) + id := "openai/gpt-4o" + + l.incrCounter(kindModel, id, metricReq, windowDay, historyStepBack(now, windowDay, 3), 100, dayWindowTTL) // 3 days ago + l.incrCounter(kindModel, id, metricReq, windowDay, historyStepBack(now, windowDay, 0), 7, dayWindowTTL) // today + + gotNow, ok := l.spanTotalsMulti(kindModel, []string{id}, []string{metricReq}, windowDay, now, 1, 0) + if !ok || gotNow[0][0] != 7 { + t.Errorf("offset=0 = %v, ok=%v, want [[7]]", gotNow, ok) + } + + gotOffset, ok := l.spanTotalsMulti(kindModel, []string{id}, []string{metricReq}, windowDay, now, 1, 3) + if !ok || gotOffset[0][0] != 100 { + t.Errorf("offset=3 = %v, ok=%v, want [[100]]", gotOffset, ok) + } +} + +// TestAccountWith_ZeroValueExtras_AddsNoFailoverLatencyOrCacheEntries is a +// smoke test that account (accountWith with a zero-value accountExtras) +// never adds a fover/latency/cmiss entry — those three are gated purely by +// accountExtras fields, unlike umodel (gated by limiter flags + scope +// shape, exercised separately above), so they must stay silent regardless +// of statsAdmin/statsLatency when the caller passes no extras at all. +func TestAccountWith_ZeroValueExtras_AddsNoFailoverLatencyOrCacheEntries(t *testing.T) { + store := &recordingStore{} + l := newLimiter(store, true) + l.statsAdmin = true + l.statsLatency = true + scopes := withModelScope([]limitScope{{kind: "user", id: "alice"}}, "openai/gpt-4o") + l.account(scopes, usage{prompt: 10, completion: 5}, 1000) + + for _, e := range store.entries { + if strings.Contains(e.key, ":"+metricFover+":") || strings.Contains(e.key, ":ld") || strings.Contains(e.key, ":lt") || strings.Contains(e.key, ":"+metricCmiss+":") { + t.Errorf("unexpected extras-driven entry from account() with zero-value extras: %q", e.key) + } + } +} diff --git a/llmgateway.go b/llmgateway.go index 56b84f1..2f60eba 100644 --- a/llmgateway.go +++ b/llmgateway.go @@ -9,6 +9,7 @@ import ( "fmt" "net" "net/http" + "net/url" "os" "runtime" "strings" @@ -510,8 +511,35 @@ type TargetHealthConfig struct { // exactly: no /admin* route is registered, so those paths fall through // to ServeHTTP's existing 404/passthroughUnknown handling like any other // unrecognized path. +// +// Stats (admin-redesign WP-A, DECISIONS Q4) is nil for every config that +// predates it, or explicitly omitted — additive and default-preserving +// (house rule): a nil Stats behaves exactly like &AdminStatsConfig{} would +// (every sub-flag off), never an error, so an upgrade with no config +// change adds no new counter writes. newConfiguredLimiter (below) reads +// this alongside Enabled to derive the limiter's own statsAdmin/ +// statsUserModel/statsLatency fields. type AdminConfig struct { - Enabled bool `json:"enabled,omitempty"` + Stats *AdminStatsConfig `json:"stats,omitempty"` + Enabled bool `json:"enabled,omitempty"` +} + +// AdminStatsConfig opts into two counter families this task adds that are +// NOT part of the "always-on-with-admin" set (DECISIONS): both default to +// false (off) even when Admin.Enabled is true, since each has its own, +// separate cost/cardinality trade-off an operator should choose +// explicitly rather than inherit silently from turning the dashboard on. +type AdminStatsConfig struct { + // UserModel opts into the per-(user, model) usage breakdown + // (kindUserModel counters, limits.go) — cardinality grows with + // users x models actually used, unlike every always-on family. + UserModel bool `json:"userModel,omitempty"` + // Latency opts into the per-bucket upstream latency counters + // (accountWith's own latency block, limits.go) — up to 8 more + // counterIncr entries per request (4 duration + 4 TTFB buckets) riding + // the existing account batch, only ever written when a candidate's + // watchdogBody actually reports a latencySample. + Latency bool `json:"latency,omitempty"` } // ModelPricing overrides the built-in per-model price table. @@ -1065,6 +1093,37 @@ func newGateway(ctx context.Context, next http.Handler, config *Config, name str } g.adapters = adapters + // P5 fix, additive part (admin dashboard redesign verify round): warn + // — never fail construction — about a provider baseUrl that does not + // parse as a URL. buildAdapters (providers.go) only ever trims a + // trailing slash off BaseURL; it never validated the value itself, so + // an operator's typo (an unescaped '/' in embedded userinfo, a + // malformed '%' escape, ...) previously surfaced only indirectly, as + // sanitizeBaseURL's own placeholder (admin.go) wherever the dashboard + // echoed it back. A warning, not a construction error, keeps this + // additive and default-preserving: a deployment that has run for a + // long time with an unparseable baseUrl keeps running unchanged, now + // with an explicit signal in GET /admin/api/overview's Warnings field + // instead of a silently redacted dashboard value. Iteration order over + // config.Providers is unspecified (Go map order), which is fine here: + // every warning is independent and their relative order is never + // observed by anything but a human reading the list. + for name, pc := range config.Providers { + if pc == nil || pc.BaseURL == "" { + continue + } + // The parse error itself is NEVER interpolated into this message: + // Go's *url.Error.Error() embeds the raw input string verbatim + // ("parse \"\": "), which is exactly the credential- + // bearing value sanitizeBaseURL exists to withhold — this warning + // would otherwise reopen the same leak through g.configWarnings / + // GET /admin/api/overview's own Warnings field instead of through + // baseUrl directly. + if _, parseErr := url.Parse(pc.BaseURL); parseErr != nil { + g.warnf("config: provider %q baseUrl does not parse as a URL; the admin dashboard will show it redacted as %q", name, unparseableBaseURLPlaceholder) + } + } + // targetTimeout: the global default only (resolveRequestTimeout, // timeout.go) — an MCP/A2A target has no per-target override the way a // provider does (ProviderConfig.RequestTimeout). Resolved again here, @@ -1189,15 +1248,30 @@ func buildRedisClient(rc *RedisConfig) (*respClient, error) { // defaults to true (a Redis outage must not take the whole gateway down // unless an operator opts into strict enforcement) unless // RedisConfig.FailOpen is explicitly set. +// +// admin-redesign WP-A: also derives the limiter's own statsAdmin/ +// statsUserModel/statsLatency fields from config.Admin, regardless of +// which store branch built l — no new constructor parameter, since +// newLimiter's own signature (store, failOpen) is a public contract this +// task does not touch; every field it does not set here (lastSeen) is +// already initialized by newLimiter itself. func newConfiguredLimiter(config *Config, client *respClient) *limiter { + var l *limiter if client == nil { - return newLimiter(nil, true) + l = newLimiter(nil, true) + } else { + failOpen := true + if config.Redis.FailOpen != nil { + failOpen = *config.Redis.FailOpen + } + l = newLimiter(newRedisStore(client), failOpen) } - failOpen := true - if config.Redis.FailOpen != nil { - failOpen = *config.Redis.FailOpen + l.statsAdmin = adminEnabled(config) + if l.statsAdmin && config.Admin.Stats != nil { + l.statsUserModel = config.Admin.Stats.UserModel + l.statsLatency = config.Admin.Stats.Latency } - return newLimiter(newRedisStore(client), failOpen) + return l } // attachUsersFile performs the synchronous initial load of a Users.File path diff --git a/llmgateway_test.go b/llmgateway_test.go index 1e9e2f0..54a75c2 100644 --- a/llmgateway_test.go +++ b/llmgateway_test.go @@ -1083,3 +1083,66 @@ func TestSendStartupTelemetry_OnceAcrossConcurrentCalls(t *testing.T) { } wg.Wait() // -race proves the Once is the only synchronisation needed } + +// --- admin-redesign WP-A: AdminConfig.Stats + newConfiguredLimiter wiring --- + +// TestNewConfiguredLimiter_StatsFlags_DeriveFromAdminConfig proves +// newConfiguredLimiter derives statsAdmin/statsUserModel/statsLatency +// purely from Config.Admin — statsUserModel/statsLatency are ANDed with +// statsAdmin at construction (limiter.statsUserModel's own doc comment, +// limits.go), so a call site need only check the one field, never both. +func TestNewConfiguredLimiter_StatsFlags_DeriveFromAdminConfig(t *testing.T) { + cases := []struct { + admin *AdminConfig + name string + wantStatsAdmin bool + wantStatsUserModel bool + wantStatsLatency bool + }{ + {name: "nil Admin: every flag off", admin: nil}, + {name: "Admin disabled: every flag off", admin: &AdminConfig{Enabled: false}}, + {name: "Admin enabled, nil Stats: statsAdmin on, sub-flags off", + admin: &AdminConfig{Enabled: true}, wantStatsAdmin: true}, + {name: "Admin enabled, Stats zero value: statsAdmin on, sub-flags off", + admin: &AdminConfig{Enabled: true, Stats: &AdminStatsConfig{}}, wantStatsAdmin: true}, + {name: "Admin enabled, both sub-flags on", + admin: &AdminConfig{Enabled: true, Stats: &AdminStatsConfig{UserModel: true, Latency: true}}, + wantStatsAdmin: true, wantStatsUserModel: true, wantStatsLatency: true}, + {name: "Admin disabled but Stats set: every flag off (statsAdmin gates the sub-flags too)", + admin: &AdminConfig{Enabled: false, Stats: &AdminStatsConfig{UserModel: true, Latency: true}}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + cfg := &Config{Admin: c.admin} + l := newConfiguredLimiter(cfg, nil) // nil client: fallback-only path, still must derive the flags + if l.statsAdmin != c.wantStatsAdmin { + t.Errorf("statsAdmin = %v, want %v", l.statsAdmin, c.wantStatsAdmin) + } + if l.statsUserModel != c.wantStatsUserModel { + t.Errorf("statsUserModel = %v, want %v", l.statsUserModel, c.wantStatsUserModel) + } + if l.statsLatency != c.wantStatsLatency { + t.Errorf("statsLatency = %v, want %v", l.statsLatency, c.wantStatsLatency) + } + if l.lastSeen == nil { + t.Error("lastSeen is nil; newLimiter must always construct one") + } + }) + } +} + +// TestNewLimiter_DirectConstruction_EveryStatsFlagDefaultsFalse proves a +// limiter built directly via newLimiter (as most of this package's own +// tests do, bypassing newConfiguredLimiter/Config entirely) defaults every +// admin-stats flag to false — additive and default-preserving: existing +// tests must observe byte-identical behavior to before this task unless +// they opt in explicitly. +func TestNewLimiter_DirectConstruction_EveryStatsFlagDefaultsFalse(t *testing.T) { + l := newLimiter(nil, true) + if l.statsAdmin || l.statsUserModel || l.statsLatency { + t.Errorf("statsAdmin=%v statsUserModel=%v statsLatency=%v, want all false", l.statsAdmin, l.statsUserModel, l.statsLatency) + } + if l.lastSeen == nil { + t.Error("lastSeen is nil; newLimiter must always construct one, regardless of stats config") + } +} diff --git a/mcp_a2a.go b/mcp_a2a.go index 4853a49..c1a545f 100644 --- a/mcp_a2a.go +++ b/mcp_a2a.go @@ -211,7 +211,14 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u writeLimitViolation(w, violation) return } - g.limiter.countTargetRequest(targetScopeKind(kind), name) + // P9 fix (admin dashboard redesign verify round): last-seen only for + // an admitted request — recordLastSeen's own doc comment (limits.go). + g.limiter.recordLastSeen(scopes, g.limiter.now()) + // Target x caller (always-on-with-admin, DECISIONS): u.name identifies + // the caller whose own credential authorized this proxy — see + // countTargetRequestBy's own doc comment (limits.go) for the id shape + // and admin-stats gating. + g.limiter.countTargetRequestBy(u.name, targetScopeKind(kind), name) upstreamURL, err := buildUpstreamTargetURL(targetURL, rest, r.URL.RawQuery) if err != nil { diff --git a/mcp_a2a_test.go b/mcp_a2a_test.go index ee3f53f..323bd8c 100644 --- a/mcp_a2a_test.go +++ b/mcp_a2a_test.go @@ -1306,3 +1306,48 @@ func TestHandleTargetProxy_TargetHealth_A2A_UsesAgentKind(t *testing.T) { t.Errorf("mcp/alpha state = %q, want unknown (must not be conflated with the agent of the same name)", gw.targetHealth.stateOf(targetKindMCP, "alpha")) } } + +// --- admin-redesign WP-A step 8: target x caller --- + +// TestHandleTargetProxy_CountsTargetCaller_OnlyWithAdminStats proves +// handleTargetProxy threads the authenticated caller's own name +// (u.name) into countTargetRequestBy — a proxied MCP request must move +// targetCallerScopeID("mcp", "alpha", "alice")'s own req counter when +// admin stats are on, and must not when they are off. +func TestHandleTargetProxy_CountsTargetCaller_OnlyWithAdminStats(t *testing.T) { + for _, adminOn := range []bool{false, true} { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}} + cfg.MCPServers = map[string]*TargetConfig{"alpha": {URL: srv.URL}} + cfg.Groups = map[string]*GroupConfig{"default": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}} + if adminOn { + cfg.Admin = &AdminConfig{Enabled: true} + } + h, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "llmgw") + if err != nil { + srv.Close() + t.Fatalf("New: %v", err) + } + gw := h.(*Gateway) + + req := httptest.NewRequest(http.MethodGet, "/mcp/alpha/tools/list", nil) + req.Header.Set("Authorization", "Bearer sk-alice") + h.ServeHTTP(httptest.NewRecorder(), req) + srv.Close() + + id := targetCallerScopeID(targetKindMCP, "alpha", "alice") + got, ok := gw.limiter.getCounter(kindTargetCaller, id, metricReq, windowDay, time.Now()) + want := int64(0) + if adminOn { + want = 1 + } + if !ok || got != want { + t.Errorf("adminOn=%v: tcaller req/day = %d (ok=%v), want %d", adminOn, got, ok, want) + } + } +} diff --git a/mcp_federation.go b/mcp_federation.go index e721ac2..a3735a4 100644 --- a/mcp_federation.go +++ b/mcp_federation.go @@ -467,6 +467,9 @@ func (g *Gateway) handleMCPFederated(w http.ResponseWriter, r *http.Request, u * writeLimitViolation(w, violation) return } + // P9 fix (admin dashboard redesign verify round): last-seen only for + // an admitted request — recordLastSeen's own doc comment (limits.go). + g.limiter.recordLastSeen(scopes, g.limiter.now()) switch { case req.Method == "initialize": @@ -474,9 +477,9 @@ func (g *Gateway) handleMCPFederated(w http.ResponseWriter, r *http.Request, u * case req.Method == "ping": writeJSONRPCResult(w, format, req.ID, map[string]any{}) case req.Method == "tools/list": - g.mcpFederatedToolsList(w, format, r, req, allowedMCPServerNames(g.cfg, grp)) + g.mcpFederatedToolsList(w, format, r, req, allowedMCPServerNames(g.cfg, grp), u.name) case req.Method == "tools/call": - g.mcpFederatedToolsCall(w, format, r, req, grp) + g.mcpFederatedToolsCall(w, format, r, req, grp, u.name) case strings.HasPrefix(req.Method, "notifications/"): // A JSON-RPC notification carries no id and gets no response body // by definition; MCP's Streamable HTTP transport answers a @@ -1188,7 +1191,7 @@ const mcpFederatedFanoutConcurrency = 8 // reach, because every call for that name is routed elsewhere). Logs // exactly which server's tool was dropped and which server tools/call // actually routes this name to. -func (g *Gateway) mcpFederatedToolsList(w http.ResponseWriter, format mcpResponseFormat, r *http.Request, req jsonrpcRequest, names []string) { +func (g *Gateway) mcpFederatedToolsList(w http.ResponseWriter, format mcpResponseFormat, r *http.Request, req jsonrpcRequest, names []string, caller string) { var ( mu sync.Mutex wg sync.WaitGroup @@ -1416,7 +1419,11 @@ func (g *Gateway) mcpFederatedToolsList(w http.ResponseWriter, format mcpRespons for i, name := range dialed { attempted[i] = limitScope{kind: targetKindMCP, id: name} } - g.limiter.countTargetRequests(attempted) + // Target x caller (always-on-with-admin, DECISIONS): caller is + // handleMCPFederated's own u.name, threaded through so every + // dialed server's own tcaller counter attributes to the caller + // whose credential authorized this fan-out. + g.limiter.countTargetRequestsBy(caller, attempted) } if len(failed) > 0 { @@ -1548,7 +1555,7 @@ func resolveFederatedTool(fullName string, allowedNames []string) (serverName, t // single resolved server, once the call was actually attempted against // it — the same "attempted, not necessarily succeeded" accounting // mcpFederatedToolsList's own doc comment explains for its own fan-out. -func (g *Gateway) mcpFederatedToolsCall(w http.ResponseWriter, format mcpResponseFormat, r *http.Request, req jsonrpcRequest, grp *group) { +func (g *Gateway) mcpFederatedToolsCall(w http.ResponseWriter, format mcpResponseFormat, r *http.Request, req jsonrpcRequest, grp *group, caller string) { var params mcpToolCallParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { writeJSONRPCErrorResponse(w, format, req.ID, jsonrpcInvalidParams, "invalid params") @@ -1588,7 +1595,9 @@ func (g *Gateway) mcpFederatedToolsCall(w http.ResponseWriter, format mcpRespons if err == nil || !errors.Is(err, context.Canceled) { g.targetHealth.record(targetKindMCP, serverName, targetURL, err == nil, err, time.Since(probeStart), targetHealthSourceTraffic) } - g.limiter.countTargetRequest(targetKindMCP, serverName) + // Target x caller (always-on-with-admin, DECISIONS): caller is + // handleMCPFederated's own u.name, threaded through. + g.limiter.countTargetRequestBy(caller, targetKindMCP, serverName) if err != nil { // P4 (review round 4): err here is commonly a *url.Error wrapping // targetURL verbatim, including its query string (this gateway's diff --git a/mcp_federation_test.go b/mcp_federation_test.go index f83be4a..7dddaba 100644 --- a/mcp_federation_test.go +++ b/mcp_federation_test.go @@ -2807,3 +2807,74 @@ func TestHandleMCPFederated_ToolsCall_ClientCanceled_RecordsNothing(t *testing.T t.Errorf("alpha state = %q, want unknown (a client cancel must record nothing)", got) } } + +// --- admin-redesign WP-A step 8: target x caller (federated routes) --- + +// TestHandleMCPFederated_ToolsCall_CountsTargetCaller proves +// mcpFederatedToolsCall threads handleMCPFederated's own u.name into +// countTargetRequestBy — the resolved server's own tcaller counter must +// move when admin stats are on. +func TestHandleMCPFederated_ToolsCall_CountsTargetCaller(t *testing.T) { + alpha := newMockJSONRPCServer(t, nil) + beta := newMockJSONRPCServer(t, nil) + cfg := newFederationTestConfig(alpha.srv.URL, beta.srv.URL, false) + cfg.Admin = &AdminConfig{Enabled: true} + h, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + + callParams, _ := json.Marshal(mcpToolCallParams{Name: "alpha_lookup"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newFederatedRequest(t, "sk-alice", jsonrpcRequest{ + JSONRPC: jsonrpcVersion, Method: "tools/call", ID: json.RawMessage("1"), Params: callParams, + })) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + id := targetCallerScopeID(targetKindMCP, "alpha", "alice") + got, ok := gw.limiter.getCounter(kindTargetCaller, id, metricReq, windowDay, time.Now()) + if !ok || got != 1 { + t.Errorf("tcaller req/day = %d (ok=%v), want 1", got, ok) + } +} + +// TestHandleMCPFederated_ToolsList_CountsTargetCaller_ForEveryDialedServer +// proves the fan-out threads the caller through to EVERY server actually +// dialed, not just one — mirroring countTargetRequests' own "dialed, not +// merely attempted" accounting (mcpFederatedToolsList's own doc comment). +func TestHandleMCPFederated_ToolsList_CountsTargetCaller_ForEveryDialedServer(t *testing.T) { + alpha := newMockJSONRPCServer(t, []mcpTool{{Name: "lookup", Description: "alpha's tool"}}) + beta := newMockJSONRPCServer(t, []mcpTool{{Name: "search", Description: "beta's tool"}}) + cfg := newFederationTestConfig(alpha.srv.URL, beta.srv.URL, false) + cfg.Admin = &AdminConfig{Enabled: true} + h, err := New(context.Background(), http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newFederatedRequest(t, "sk-alice", jsonrpcRequest{ + JSONRPC: jsonrpcVersion, Method: "tools/list", ID: json.RawMessage("1"), + })) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + for _, name := range []string{"alpha", "beta"} { + id := targetCallerScopeID(targetKindMCP, name, "alice") + got, ok := gw.limiter.getCounter(kindTargetCaller, id, metricReq, windowDay, time.Now()) + if !ok || got != 1 { + t.Errorf("server %q: tcaller req/day = %d (ok=%v), want 1", name, got, ok) + } + } +} diff --git a/metrics.go b/metrics.go index f9612d7..2511bc1 100644 --- a/metrics.go +++ b/metrics.go @@ -871,16 +871,33 @@ func newLatencyHistogram() *latencyHistogram { // first bound the observation is <= is its bucket, matching Prometheus's // own "le" (less-than-or-equal) histogram semantics exactly. func observeLatencyBucket(buckets []int64, overflow *int64, sum *float64, count *int64, d time.Duration) { - v := d.Seconds() - *sum += v + *sum += d.Seconds() *count++ + idx, isOverflow := latencyBucketIndex(d) + if isOverflow { + *overflow++ + return + } + buckets[idx]++ +} + +// latencyBucketIndex returns d's index into latencyBucketBounds — the +// first bound d.Seconds() is <= to — or overflow=true, with idx equal to +// len(latencyBucketBounds), when d exceeds every configured bound. Shared +// by observeLatencyBucket (this in-process Prometheus histogram, above) +// and limits.go's accountWith/latencyCounterEntries (admin-redesign WP-A, +// admin.stats.latency's own opt-in per-bucket counter family), so the two +// can never disagree about which bucket a given duration belongs to — +// accountWith's own counter names (latencyDurationMetric/ +// latencyTTFBMetric, limits.go) are built directly from this same index. +func latencyBucketIndex(d time.Duration) (idx int, overflow bool) { + v := d.Seconds() for i, bound := range latencyBucketBounds { if v <= bound { - buckets[i]++ - return + return i, false } } - *overflow++ + return len(latencyBucketBounds), true } // latencyStore is the Gateway's in-process, per-replica upstream-latency diff --git a/metrics_test.go b/metrics_test.go index 9ecfc9c..6dc2e21 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -1773,3 +1773,65 @@ func TestMetrics_TargetHealthy_UnaffectedByStoreOutage(t *testing.T) { _, samples := parsePrometheusText(t, rec.Body.Bytes()) assertSample(t, samples, "llmgateway_target_healthy", map[string]string{"kind": "mcp", "target": "alpha"}, "1") } + +// --- admin-redesign WP-A: latencyBucketIndex --- + +// TestLatencyBucketIndex_BoundaryValuesAndOverflow pins latencyBucketIndex +// (the helper accountWith's own opt-in per-bucket latency counters share +// with this file's in-process observeLatencyBucket, limits.go) against +// latencyBucketBounds' own exact boundary values: a duration equal to a +// bound belongs to THAT bound's own bucket (le semantics, matching +// Prometheus histogram convention), and anything past the last bound +// overflows to index len(latencyBucketBounds). +func TestLatencyBucketIndex_BoundaryValuesAndOverflow(t *testing.T) { + for i, bound := range latencyBucketBounds { + d := time.Duration(bound * float64(time.Second)) + idx, overflow := latencyBucketIndex(d) + if overflow { + t.Errorf("bound[%d]=%v: overflow=true, want false", i, bound) + } + if idx != i { + t.Errorf("bound[%d]=%v: idx=%d, want %d", i, bound, idx, i) + } + } + + idx, overflow := latencyBucketIndex(0) + if overflow || idx != 0 { + t.Errorf("d=0: idx=%d overflow=%v, want 0, false", idx, overflow) + } + + last := latencyBucketBounds[len(latencyBucketBounds)-1] + past := time.Duration(last*float64(time.Second)) + time.Second + idx, overflow = latencyBucketIndex(past) + if !overflow || idx != len(latencyBucketBounds) { + t.Errorf("d past every bound: idx=%d overflow=%v, want %d, true", idx, overflow, len(latencyBucketBounds)) + } +} + +// TestObserveLatencyBucket_StillMatchesLatencyBucketIndexAfterRefactor +// proves observeLatencyBucket's own dense-bucket/overflow/sum/count +// bookkeeping stays byte-for-byte correct after being refactored to share +// latencyBucketIndex, for both an in-range and an overflowing sample. +func TestObserveLatencyBucket_StillMatchesLatencyBucketIndexAfterRefactor(t *testing.T) { + buckets := make([]int64, len(latencyBucketBounds)) + var overflow int64 + var sum float64 + var count int64 + + observeLatencyBucket(buckets, &overflow, &sum, &count, 2*time.Second) + wantIdx, _ := latencyBucketIndex(2 * time.Second) + if buckets[wantIdx] != 1 { + t.Errorf("buckets[%d] = %d, want 1", wantIdx, buckets[wantIdx]) + } + if count != 1 || sum != 2 { + t.Errorf("count=%d sum=%v, want 1, 2", count, sum) + } + + observeLatencyBucket(buckets, &overflow, &sum, &count, 3600*time.Second) + if overflow != 1 { + t.Errorf("overflow = %d, want 1", overflow) + } + if count != 2 { + t.Errorf("count = %d, want 2", count) + } +} diff --git a/pricing.go b/pricing.go index 0191768..92af10d 100644 --- a/pricing.go +++ b/pricing.go @@ -190,6 +190,54 @@ func saturatingAddInt64(a, b int64) int64 { return sum } +// priceSourceOverride, priceSourceBuiltin, priceSourceLitellm, +// priceSourceFree and priceSourceUnpriced enumerate every value +// billingPriceSource (below) and GET /admin/api/catalog's own +// adminCatalogModel.PriceSource field (admin_catalog.go) can report — +// lookupPricingSource's own three lookup layers, plus modelMetaFree's +// explicit "free", plus "no price found at all". A model whose id +// merely carries the freeTierSuffix (":free", modelmeta.go) resolves to +// priceSourceUnpriced here — that DISPLAY-only free-tier naming +// convention never counts as a real billing source on its own; GET +// /admin/api/catalog surfaces it through its own separate DisplayFree +// field instead (DECISIONS Q3 — see billingPriceSource's own doc +// comment below). +const ( + priceSourceOverride = "override" + priceSourceBuiltin = "builtin" + priceSourceLitellm = "litellm" + priceSourceFree = "free" + priceSourceUnpriced = "unpriced" +) + +// lookupPricingSource resolves model's price exactly like lookupPricing +// (below, now a thin wrapper around this), additionally reporting WHICH +// layer matched: priceSourceOverride, priceSourceBuiltin, or +// priceSourceLitellm, in that same precedence order — an operator +// override always wins, builtinPricing (curated, more likely to be +// current) wins over the generated table next, and the generated table +// is only ever the last resort (F2, 2026-09 review — see lookupPricing's +// own doc comment, unchanged below, for the full per-layer reasoning). +// ok is false, with source == "", when no layer prices model at all. +// This function's own resolution is byte-for-byte identical to +// lookupPricing's prior direct implementation; it only ADDS the source +// label GET /admin/api/catalog needs (billingPriceSource, below). +func lookupPricingSource(model string, overrides map[string]*ModelPricing) (price ModelPricing, source string, ok bool) { + if p, ok := overrides[model]; ok && p != nil { + return *p, priceSourceOverride, true + } + if p, ok := builtinPricing[model]; ok { + return p, priceSourceBuiltin, true + } + if m, ok := builtinModelMetaTable[model]; ok && (m.InputCostPerMTokMicroUSD > 0 || m.OutputCostPerMTokMicroUSD > 0) { + return ModelPricing{ + InputPerM: microUSDPerMTokToUSD(m.InputCostPerMTokMicroUSD), + OutputPerM: microUSDPerMTokToUSD(m.OutputCostPerMTokMicroUSD), + }, priceSourceLitellm, true + } + return ModelPricing{}, "", false +} + // lookupPricing resolves model's price: overrides first (a nil entry does // not count as a match, so a caller can't accidentally price a model at // zero by leaving a map entry nil), then builtinPricing, then @@ -213,20 +261,51 @@ func saturatingAddInt64(a, b int64) int64 { // an operator override always wins, builtinPricing (curated, more likely // to be current) wins over the generated table next, and the generated // table is only ever the last resort. +// +// A thin wrapper around lookupPricingSource (above, GET /admin/api/catalog +// feature) since the extension here needed both the price and which layer +// produced it — this call site only ever wanted the price. func lookupPricing(model string, overrides map[string]*ModelPricing) (ModelPricing, bool) { - if p, ok := overrides[model]; ok && p != nil { - return *p, true + price, _, ok := lookupPricingSource(model, overrides) + return price, ok +} + +// billingPriceSource reports the SAME price/source billing itself would +// charge for a request serving canonical ("provider/model") / bare (the +// upstream model id alone) — mirroring unifiedCostMicros' own resolution +// order EXACTLY (routes_unified.go): modelMetaFree first (an operator +// modelMeta:{free:true} entry always wins, priceSourceFree, zero price), +// then lookupPricingSource against canonical, then against bare, then +// priceSourceUnpriced (ModelPricing{}) when neither carries a price at +// any layer. GET /admin/api/catalog's own "billing truth" PriceSource +// field (admin_catalog.go) calls this directly so the catalog view and +// actual billing can never disagree about which price a model resolves +// to — the same guarantee modelMetaFree's own doc comment already +// promises between unifiedCostMicros and priceKnown. +// +// A ":free"-suffixed model id (freeTierSuffix, modelmeta.go) is NOT +// specially handled here: unifiedCostMicros itself never consults that +// suffix either (it only affects resolveModelMeta's DISPLAY-path cost +// resolution, GET /v1/models), so such an id resolves through the normal +// override/builtin/litellm/unpriced layers exactly like any other id — +// usually priceSourceUnpriced, since the suffix rarely matches a real +// priced entry. A caller wanting to also surface that naming convention +// (DECISIONS Q3: "':free' suffix models: report priceSource 'unpriced' + +// displayFree; billing unchanged") checks strings.HasSuffix(bare, +// freeTierSuffix) itself, alongside this result (admin_catalog.go) — that +// flag is display-only and must never change what this function reports +// as the billing source. +func billingPriceSource(canonical, bare string, overrides map[string]*ModelPricing, meta map[string]*ModelMetaConfig) (source string, price ModelPricing) { + if modelMetaFree(canonical, bare, meta) { + return priceSourceFree, ModelPricing{} } - if p, ok := builtinPricing[model]; ok { - return p, true + if p, src, ok := lookupPricingSource(canonical, overrides); ok { + return src, p } - if m, ok := builtinModelMetaTable[model]; ok && (m.InputCostPerMTokMicroUSD > 0 || m.OutputCostPerMTokMicroUSD > 0) { - return ModelPricing{ - InputPerM: microUSDPerMTokToUSD(m.InputCostPerMTokMicroUSD), - OutputPerM: microUSDPerMTokToUSD(m.OutputCostPerMTokMicroUSD), - }, true + if p, src, ok := lookupPricingSource(bare, overrides); ok { + return src, p } - return ModelPricing{}, false + return priceSourceUnpriced, ModelPricing{} } // validatePricing validates every entry in raw (Config.Pricing overrides) diff --git a/pricing_test.go b/pricing_test.go index 673a41c..753d4e5 100644 --- a/pricing_test.go +++ b/pricing_test.go @@ -283,6 +283,137 @@ func TestLookupPricing_OverrideWinsOverGeneratedTable(t *testing.T) { // the exact "small cheap request silently bills 0" gap the finding // reports, which also meant account (limits.go) skipped the cost-counter // write entirely for that traffic. +// TestLookupPricingSource_MatchesLookupPricing pins lookupPricing as a +// thin wrapper around lookupPricingSource (admin dashboard redesign, +// WP-B): for every layer (override, builtin, generated table, unknown), +// the two must agree on price and ok, and lookupPricingSource must report +// the correct source label for each. +func TestLookupPricingSource_MatchesLookupPricing(t *testing.T) { + overrides := map[string]*ModelPricing{"custom-model": {InputPerM: 7, OutputPerM: 7}} + cases := []struct { + model string + wantSource string + wantOK bool + }{ + {"custom-model", priceSourceOverride, true}, + {"gpt-5", priceSourceBuiltin, true}, // builtinPricing entry + {"deepseek-chat", priceSourceLitellm, true}, // generated table only + {"totally-unknown-model-xyz", "", false}, + } + for _, c := range cases { + t.Run(c.model, func(t *testing.T) { + price, source, ok := lookupPricingSource(c.model, overrides) + if ok != c.wantOK || source != c.wantSource { + t.Fatalf("lookupPricingSource(%q) = (%+v, %q, %v), want source=%q ok=%v", c.model, price, source, ok, c.wantSource, c.wantOK) + } + wantPrice, wantOK := lookupPricing(c.model, overrides) + if wantOK != ok || wantPrice != price { + t.Errorf("lookupPricing(%q) = (%+v, %v) disagrees with lookupPricingSource = (%+v, %v)", c.model, wantPrice, wantOK, price, ok) + } + }) + } +} + +// TestBillingPriceSource_Table exercises every layer billingPriceSource +// resolves through, in precedence order (DECISIONS Q3 for the ':free' +// suffix case): modelMetaFree wins outright; then an override on the +// canonical "provider/model" id; then an override on the bare id alone; +// then builtinPricing; then the generated LiteLLM table; then unpriced — +// including a ':free'-suffixed bare id that resolves to NOTHING in any +// table, which must still report priceSourceUnpriced (billing truth is +// unaffected by the naming convention; DisplayFree is admin_catalog.go's +// own, separate concern). +func TestBillingPriceSource_Table(t *testing.T) { + overrides := map[string]*ModelPricing{ + "providerA/canonical-model": {InputPerM: 1, OutputPerM: 2}, + "bare-override-model": {InputPerM: 3, OutputPerM: 4}, + } + meta := map[string]*ModelMetaConfig{ + "providerA/free-model": {Free: true}, + } + cases := []struct { + name string + canonical string + bare string + wantSource string + wantPrice ModelPricing + }{ + {"modelMeta free wins over everything", "providerA/free-model", "free-model", priceSourceFree, ModelPricing{}}, + {"override on canonical id", "providerA/canonical-model", "canonical-model", priceSourceOverride, ModelPricing{InputPerM: 1, OutputPerM: 2}}, + {"override on bare id when canonical has none", "providerB/bare-override-model", "bare-override-model", priceSourceOverride, ModelPricing{InputPerM: 3, OutputPerM: 4}}, + {"builtin table", "providerC/gpt-5", "gpt-5", priceSourceBuiltin, builtinPricing["gpt-5"]}, + {"generated table fallback", "providerC/deepseek-chat", "deepseek-chat", priceSourceLitellm, mustLookupPrice(t, "deepseek-chat", nil)}, + {"free-tier-suffixed id with no real price is unpriced, not free", "providerD/some-model:free", "some-model:free", priceSourceUnpriced, ModelPricing{}}, + {"totally unknown model", "providerD/unknown-xyz", "unknown-xyz", priceSourceUnpriced, ModelPricing{}}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + source, price := billingPriceSource(c.canonical, c.bare, overrides, meta) + if source != c.wantSource { + t.Errorf("billingPriceSource(%q, %q) source = %q, want %q", c.canonical, c.bare, source, c.wantSource) + } + if price != c.wantPrice { + t.Errorf("billingPriceSource(%q, %q) price = %+v, want %+v", c.canonical, c.bare, price, c.wantPrice) + } + }) + } +} + +// mustLookupPrice is a small test helper for TestBillingPriceSource_Table's +// generated-table case, so its "want" value stays derived from the real +// table rather than a value that silently drifts if the table changes. +func mustLookupPrice(t *testing.T, model string, overrides map[string]*ModelPricing) ModelPricing { + t.Helper() + price, ok := lookupPricing(model, overrides) + if !ok { + t.Fatalf("test assumption broken: lookupPricing(%q) ok = false", model) + } + return price +} + +// TestBillingPriceSource_ParityWithUnifiedCostMicros pins billingPriceSource +// against unifiedCostMicros' OWN resolution order (routes_unified.go), +// which it must mirror exactly: whenever billingPriceSource reports a +// priced source (anything but unpriced), costMicrosKnownFor against the +// SAME id unifiedCostMicros itself would have picked must resolve a price +// too, and the price itself must match what billingPriceSource reports — +// so GET /admin/api/catalog's PriceSource field can never disagree with +// what a real request actually gets charged. +func TestBillingPriceSource_ParityWithUnifiedCostMicros(t *testing.T) { + overrides := map[string]*ModelPricing{ + "providerA/canonical-model": {InputPerM: 1, OutputPerM: 2}, + "bare-override-model": {InputPerM: 3, OutputPerM: 4}, + } + meta := map[string]*ModelMetaConfig{ + "providerA/free-model": {Free: true}, + } + u := usage{prompt: 1_000_000, completion: 1_000_000} + models := []struct{ canonical, bare string }{ + {"providerA/free-model", "free-model"}, + {"providerA/canonical-model", "canonical-model"}, + {"providerB/bare-override-model", "bare-override-model"}, + {"providerC/gpt-5", "gpt-5"}, + {"providerC/deepseek-chat", "deepseek-chat"}, + {"providerD/unknown-xyz", "unknown-xyz"}, + } + for _, m := range models { + t.Run(m.canonical, func(t *testing.T) { + source, price := billingPriceSource(m.canonical, m.bare, overrides, meta) + gotCost := unifiedCostMicros(m.canonical, m.bare, u, overrides, meta, func(string) {}) + if source == priceSourceUnpriced { + if gotCost != 0 { + t.Errorf("unifiedCostMicros = %d for an unpriced model, want 0", gotCost) + } + return + } + wantCost := computeCostMicros(price, u) + if gotCost != wantCost { + t.Errorf("unifiedCostMicros(%q) = %d, want %d (billingPriceSource's own price=%+v source=%q)", m.canonical, gotCost, wantCost, price, source) + } + }) + } +} + func TestCostMicrosKnown_RoundsCombinedTotal(t *testing.T) { overrides := map[string]*ModelPricing{"cheap-model": {InputPerM: 0.02, OutputPerM: 0.02}} got, ok := costMicrosKnown("cheap-model", usage{prompt: 30, completion: 30}, overrides) diff --git a/redis_store.go b/redis_store.go index 94864e4..b11eeda 100644 --- a/redis_store.go +++ b/redis_store.go @@ -41,7 +41,9 @@ func newRedisStore(client *respClient) *redisStore { // hot counters. const expireOnceCacheMax = 4096 -// needsExpireBatch is needsExpire (below) extended to a whole incrMulti/ +// needsExpireBatch is needsExpire (redis_store_helpers_test.go's +// test-only single-key wrapper — every production call site now goes +// through this batched form) extended to a whole incrMulti/ // incrAndGetMulti batch: it takes expireMu ONCE for the whole slice // instead of once per entry, computed for every entry BEFORE any of the // batch's commands are even sent (mirroring needsExpire's own @@ -53,6 +55,15 @@ func (s *redisStore) needsExpireBatch(entries []counterIncr) []bool { s.expireMu.Lock() defer s.expireMu.Unlock() for i, e := range entries { + // An absolute entry's own command (SET key v EX secs, below) + // already carries its own expiry — it never needs a SEPARATE + // EXPIRE command the way an INCRBY does, and so is never tracked + // in expireSeen at all: always false here, regardless of whether + // this exact key has been written before. + if e.absolute { + out[i] = false + continue + } _, ok := s.expireSeen[e.key] out[i] = !ok } @@ -139,45 +150,6 @@ func (s *redisStore) forgetExpireLocked(key string) { delete(s.expireSeen, key) } -// needsExpire reports whether key does NOT currently have a CONFIRMED -// EXPIRE recorded for it (perf finding 2, 2026-08-2x audit, deleting -// EXPIRE entirely measured as the upper bound: 74->43 cmds/req, p50 -// 867.9->697.9us (-19.6%), conc=16 throughput 1,477->2,336 req/s (+58%)). -// windowKey (limits.go) already embeds each counter's own time bucket in -// its key string, and the TTLs callers pass (dayWindowTTL=35d, -// monthWindowTTL=400d, ...) are deliberately far longer than their -// window's own natural length purely as a stale-key GC margin, not a -// sliding-window mechanism (limits.go's own doc comment on those -// constants) — so once EXPIRE is CONFIRMED sent for an exact key string, -// every further INCRBY against that SAME key happens well within the -// window that key's own bucket belongs to, comfortably inside the TTL's -// margin, and resending EXPIRE for it is redundant, not incorrect. -// -// needsExpire does NOT mark key as seen — unlike this method's -// predecessor (expireOnce), which marked a key seen at CHECK time, -// before its pipeline was ever sent. That was a bug (review finding, -// 2026-08-2x, CRITICAL): a pipeline carrying key's only EXPIRE can still -// fail after the mark — e.g. Redis is briefly unreachable, routine -// during a rollout — leaving this process believing EXPIRE succeeded -// when Redis never received it, and every later INCRBY against that key -// sends no EXPIRE ever again: an immortal, permanently TTL-less key -// (unbounded keyspace growth — precisely what dayWindowTTL/ -// monthWindowTTL exist to prevent — and under maxmemory-policy -// volatile-*, a TTL-less key is not evictable, so it can drive Redis to -// OOM-on-write). Marking now happens ONLY in commitExpire, called by the -// caller after confirming the pipeline that carried EXPIRE actually -// succeeded (commit-after-success). See commitExpire and forgetExpireLocked -// for the other half of the fix: Redis can also independently lose an -// already-EXPIRE-confirmed key (restart, maxmemory eviction, FLUSHDB, a -// failover to a replica missing it) without this process's cache ever -// finding out on its own. -func (s *redisStore) needsExpire(key string) bool { - s.expireMu.Lock() - defer s.expireMu.Unlock() - _, ok := s.expireSeen[key] - return !ok -} - // commitExpire records key as having a CONFIRMED EXPIRE on Redis — call // only after the pipeline carrying key's EXPIRE command is known to have // succeeded (client.pipeline returned a nil error). Bounded FIFO @@ -290,7 +262,37 @@ func (s *redisStore) applyIncrReplies(method string, entries []counterIncr, repl for i, e := range entries { reply := replies[incrReplyIdx[i]] if re, ok := reply.(error); ok { - return i, fmt.Errorf("redisStore: %s %q: INCRBY failed: %w", method, e.key, re) + // P11 fix (admin dashboard redesign verify round): an + // absolute entry's own command is SET, not INCRBY (below) — + // this error text used to say "INCRBY failed" even for a + // failed SET (e.g. a last-seen write against an ACL that + // denies SET but allows INCRBY), which would have sent an + // operator investigating the wrong command entirely. + cmd := "INCRBY" + if e.absolute { + cmd = "SET" + } + return i, fmt.Errorf("redisStore: %s %q: %s failed: %w", method, e.key, cmd, re) + } + // An absolute entry's own command is SET key v EX secs, which + // replies with the literal simple-string "OK" — resp.go decodes a + // simple-string reply to a Go string (never int64), and this is + // Yaegi-safe: reply.(string) is a comma-ok assertion of a COMPILED + // value (the resp decoder's own output) against a stdlib interface + // method set, never an interpreted plugin type on either side (the + // documented-safe direction — matchesSentinel's own doc comment, + // limits.go). out[i] is set to e.delta itself (the value SET wrote), + // since a SET reply carries no post-write count the way INCRBY's + // does; no EXPIRE was ever sent for it (needsExpireBatch's own + // absolute branch), so confirmed[i]/firstWrite[i] stay at their + // zero value — nothing here needs expire bookkeeping. + if e.absolute { + s, ok := reply.(string) + if !ok || s != "OK" { + return i, fmt.Errorf("redisStore: %s %q: unexpected SET reply %v (%T)", method, e.key, reply, reply) + } + out[i] = e.delta + continue } v, ok := reply.(int64) if !ok { @@ -307,10 +309,16 @@ func (s *redisStore) applyIncrReplies(method string, entries []counterIncr, repl return len(entries), nil } -// incrMulti implements counterStore: every entry's INCRBY, plus an EXPIRE -// only when needsExpire(e.key) is true, sent as ONE pipeline (perf -// review, 2026-08-21) — one round trip regardless of len(entries) or how -// many of them get an EXPIRE this time. This is the only production path +// incrMulti implements counterStore: every entry's INCRBY (or, for an +// absolute entry, a SET — counterIncr.absolute's own doc comment) plus an +// EXPIRE only when needsExpireBatch(entries) says so for that entry +// (P11 fix, admin dashboard redesign verify round: this comment named +// needsExpire, the single-key predecessor moved to redis_store_helpers_ +// test.go once every production call site went through the batched form +// — needsExpireBatch's own doc comment; an absolute entry never gets one +// at all, needsExpireBatch's own absolute branch), sent as ONE pipeline +// (perf review, 2026-08-21) — one round trip regardless of len(entries) +// or how many of them get an EXPIRE this time. This is the only production path // that reaches Redis for a counter write (incrBy, its single-key // predecessor, had no production caller left once checkAndCount/account // moved onto the batched paths, and was removed — deadcode audit, @@ -364,6 +372,10 @@ func (s *redisStore) incrMulti(entries []counterIncr) ([]int64, error) { sendExpire := s.needsExpireBatch(entries) for i, e := range entries { incrReplyIdx[i] = len(cmds) + if e.absolute { + cmds = append(cmds, []string{"SET", e.key, strconv.FormatInt(e.delta, 10), "EX", strconv.FormatInt(ttlToSeconds(e.ttl), 10)}) + continue + } cmds = append(cmds, []string{"INCRBY", e.key, strconv.FormatInt(e.delta, 10)}) if sendExpire[i] { cmds = append(cmds, []string{"EXPIRE", e.key, strconv.FormatInt(ttlToSeconds(e.ttl), 10)}) @@ -393,10 +405,12 @@ func (s *redisStore) incrMulti(entries []counterIncr) ([]int64, error) { return out, nil } -// incrAndGetMulti implements counterStore: every entry's INCRBY, plus an -// EXPIRE only when needsExpire(e.key) is true (same commit-after-success/ -// forget-on-recreate bookkeeping as incrMulti's own doc comment), AND -// every read key's GET, sent as ONE pipeline (perf review round 3, +// incrAndGetMulti implements counterStore: every entry's INCRBY (or SET +// for an absolute entry), plus an EXPIRE only when needsExpireBatch(entries) +// says so for that entry (P11 fix, admin dashboard redesign verify round — +// see incrMulti's own doc comment for why this no longer names needsExpire; +// same commit-after-success/forget-on-recreate bookkeeping as incrMulti's +// own doc comment), AND every read key's GET, sent as ONE pipeline (perf review round 3, // 2026-08-22) — one round trip regardless of how many of each there are, // fusing checkAndCount's own request-counter increments with its // token/cost budget reads (limits.go's counterStore doc comment explains @@ -421,6 +435,10 @@ func (s *redisStore) incrAndGetMulti(entries []counterIncr, reads []string) ([]i sendExpire := s.needsExpireBatch(entries) for i, e := range entries { incrReplyIdx[i] = len(cmds) + if e.absolute { + cmds = append(cmds, []string{"SET", e.key, strconv.FormatInt(e.delta, 10), "EX", strconv.FormatInt(ttlToSeconds(e.ttl), 10)}) + continue + } cmds = append(cmds, []string{"INCRBY", e.key, strconv.FormatInt(e.delta, 10)}) if sendExpire[i] { cmds = append(cmds, []string{"EXPIRE", e.key, strconv.FormatInt(ttlToSeconds(e.ttl), 10)}) diff --git a/redis_store_helpers_test.go b/redis_store_helpers_test.go new file mode 100644 index 0000000..89ab34f --- /dev/null +++ b/redis_store_helpers_test.go @@ -0,0 +1,17 @@ +package traefikllmgateway + +// needsExpire reports whether key does NOT currently have a CONFIRMED +// EXPIRE recorded for it — the single-key shape needsExpireBatch +// (redis_store.go) generalizes to a whole incrMulti/incrAndGetMulti +// batch. Every production call site now goes through needsExpireBatch; +// this stays as a test-only helper (admin-redesign WP-G cleanup) because +// redis_store_test.go's own coverage of expireSeen/commitExpire/ +// forgetExpireLocked's bookkeeping asserts against it directly, one key +// at a time, which is easier to read than threading every assertion +// through a []counterIncr batch. +func (s *redisStore) needsExpire(key string) bool { + s.expireMu.Lock() + defer s.expireMu.Unlock() + _, ok := s.expireSeen[key] + return !ok +} diff --git a/redis_store_test.go b/redis_store_test.go index a252a03..786647c 100644 --- a/redis_store_test.go +++ b/redis_store_test.go @@ -982,3 +982,137 @@ func TestRedisStore_Get_DownServer_ReturnsError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "redisStore: getMulti") } + +// --- admin-redesign WP-A step 1: absolute (last-seen SET) entries --- + +// TestRedisStore_IncrMulti_AbsoluteEntry_SendsSetNotIncrby is Q6's own +// wire-shape contract: an absolute counterIncr sends ONE "SET key v EX +// secs" command — never INCRBY, never a separate EXPIRE — and its reply +// (the simple string "OK") decodes to out[i] == e.delta, not the reply +// itself (a SET reply carries no post-write count the way INCRBY's does). +func TestRedisStore_IncrMulti_AbsoluteEntry_SendsSetNotIncrby(t *testing.T) { + ln := newFakeListener(t) + runFakeRESPServer(t, ln, []respStep{ + {wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")}, + {wantArgs: []string{"SET", "llmgw:seen:user:alice", "1700000000", "EX", "34560000"}, reply: []byte("+OK\r\n")}, + }) + + store := newRedisStore(newRESPClient(ln.Addr().String(), "", 0)) + got, err := store.incrMulti([]counterIncr{ + {key: "llmgw:seen:user:alice", delta: 1700000000, ttl: monthWindowTTL, absolute: true}, + }) + if err != nil { + t.Fatalf("incrMulti: %v", err) + } + if len(got) != 1 || got[0] != 1700000000 { + t.Errorf("got = %v, want [1700000000]", got) + } +} + +// TestRedisStore_IncrMulti_MixedAbsoluteAndIncr_OneEmptyPipeline proves a +// batch mixing an absolute entry with a plain INCRBY entry sends both in +// ONE pipeline — the last-seen entries checkAndCount appends ride the +// exact same round trip as the scope req increments, never a second one. +func TestRedisStore_IncrMulti_MixedAbsoluteAndIncr_OneEmptyPipeline(t *testing.T) { + ln := newFakeListener(t) + runFakeRESPServer(t, ln, []respStep{ + {wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")}, + {wantArgs: []string{"INCRBY", "llmgw:user:alice:req:min:202609231030", "1"}, reply: []byte(":1\r\n")}, + {wantArgs: []string{"EXPIRE", "llmgw:user:alice:req:min:202609231030", "120"}, reply: []byte(":1\r\n")}, + {wantArgs: []string{"SET", "llmgw:seen:user:alice", "42", "EX", "34560000"}, reply: []byte("+OK\r\n")}, + }) + + store := newRedisStore(newRESPClient(ln.Addr().String(), "", 0)) + got, err := store.incrMulti([]counterIncr{ + {key: "llmgw:user:alice:req:min:202609231030", delta: 1, ttl: minWindowTTL}, + {key: "llmgw:seen:user:alice", delta: 42, ttl: monthWindowTTL, absolute: true}, + }) + if err != nil { + t.Fatalf("incrMulti: %v", err) + } + if len(got) != 2 || got[0] != 1 || got[1] != 42 { + t.Errorf("got = %v, want [1 42]", got) + } +} + +// TestRedisStore_IncrMulti_AbsoluteEntry_UnexpectedReplyType_Errors proves +// applyIncrReplies refuses a non-"OK" reply for an absolute entry rather +// than silently treating it as a success. +func TestRedisStore_IncrMulti_AbsoluteEntry_UnexpectedReplyType_Errors(t *testing.T) { + ln := newFakeListener(t) + runFakeRESPServer(t, ln, []respStep{ + {wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")}, + {wantArgs: []string{"SET", "llmgw:seen:user:alice", "1", "EX", "60"}, reply: []byte(":1\r\n")}, // integer, not "OK" + }) + + store := newRedisStore(newRESPClient(ln.Addr().String(), "", 0)) + _, err := store.incrMulti([]counterIncr{ + {key: "llmgw:seen:user:alice", delta: 1, ttl: time.Minute, absolute: true}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected SET reply") +} + +// TestRedisStore_IncrMulti_AbsoluteEntry_ErrorReplySaysSET is P11 (admin +// dashboard redesign verify round): applyIncrReplies' error branch used +// to say "INCRBY failed" for EVERY entry's errored reply, including an +// absolute entry's own SET command (e.g. a last-seen write against an +// ACL that denies SET but allows INCRBY) — misleading an operator +// investigating the wrong command entirely. +func TestRedisStore_IncrMulti_AbsoluteEntry_ErrorReplySaysSET(t *testing.T) { + ln := newFakeListener(t) + runFakeRESPServer(t, ln, []respStep{ + {wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")}, + {wantArgs: []string{"SET", "llmgw:seen:user:alice", "1", "EX", "60"}, reply: []byte("-NOPERM this user has no permissions to run the 'set' command\r\n")}, + }) + + store := newRedisStore(newRESPClient(ln.Addr().String(), "", 0)) + _, err := store.incrMulti([]counterIncr{ + {key: "llmgw:seen:user:alice", delta: 1, ttl: time.Minute, absolute: true}, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "SET failed") + assert.NotContains(t, err.Error(), "INCRBY failed") +} + +// TestRedisStore_NeedsExpireBatch_AbsoluteEntry_AlwaysFalse proves an +// absolute entry never needs a separate EXPIRE — its own SET already +// carries EX — regardless of whether its key has ever been committed +// before. +func TestRedisStore_NeedsExpireBatch_AbsoluteEntry_AlwaysFalse(t *testing.T) { + s := newRedisStore(nil) + s.commitExpire("llmgw:seen:user:alice") // even a "committed" key stays false for an absolute entry + got := s.needsExpireBatch([]counterIncr{ + {key: "llmgw:seen:user:alice", delta: 1, absolute: true}, + {key: "llmgw:user:alice:req:min:x", delta: 1}, + }) + assert.Equal(t, []bool{false, true}, got) +} + +// TestRedisStore_IncrAndGetMulti_AbsoluteEntry_SendsSetAlongsideReads +// proves checkAndCount's own fused round trip (INCRBY/SET entries plus +// budget-probe reads) still sends an absolute entry as SET, correctly +// interleaved with the read keys that follow every entry's own commands. +func TestRedisStore_IncrAndGetMulti_AbsoluteEntry_SendsSetAlongsideReads(t *testing.T) { + ln := newFakeListener(t) + runFakeRESPServer(t, ln, []respStep{ + {wantArgs: []string{"SELECT", "0"}, reply: []byte("+OK\r\n")}, + {wantArgs: []string{"SET", "llmgw:seen:user:alice", "9", "EX", "60"}, reply: []byte("+OK\r\n")}, + {wantArgs: []string{"GET", "llmgw:user:alice:tokin:day:x"}, reply: []byte("$3\r\n100\r\n")}, + }) + + store := newRedisStore(newRESPClient(ln.Addr().String(), "", 0)) + incrVals, readVals, err := store.incrAndGetMulti( + []counterIncr{{key: "llmgw:seen:user:alice", delta: 9, ttl: time.Minute, absolute: true}}, + []string{"llmgw:user:alice:tokin:day:x"}, + ) + if err != nil { + t.Fatalf("incrAndGetMulti: %v", err) + } + if len(incrVals) != 1 || incrVals[0] != 9 { + t.Errorf("incrVals = %v, want [9]", incrVals) + } + if len(readVals) != 1 || readVals[0] != 100 { + t.Errorf("readVals = %v, want [100]", readVals) + } +} diff --git a/resp.go b/resp.go index b53f75d..a5fb236 100644 --- a/resp.go +++ b/resp.go @@ -638,12 +638,6 @@ func encodeCommands(cmds [][]string) []byte { return dst } -// encodeCommand renders args as a RESP2 command array: -// "*N\r\n$len\r\narg\r\n..." for each arg. -func encodeCommand(args []string) string { - return string(encodeCommands([][]string{args})) -} - // decodeReply reads and decodes one RESP2 reply from r. depth is 0 for a // top-level reply and depth+1 for each element decodeArray recurses into; // an array is only accepted at depth 0 (see the '*' case) — none of this diff --git a/resp_test.go b/resp_test.go index e01630a..2570763 100644 --- a/resp_test.go +++ b/resp_test.go @@ -46,6 +46,18 @@ func TestPipelineMutates_LPUSH(t *testing.T) { } } +// encodeCommand renders args as a RESP2 command array: +// "*N\r\n$len\r\narg\r\n..." for each arg — encodeCommands' (resp.go) +// single-command special case. No production call site needs a +// one-command string result on its own (every real caller batches +// through encodeCommands), so this stays as a test-only helper +// (admin-redesign WP-G cleanup) purely to keep TestEncodeCommand's +// single-command wire-format pin readable without a slice-of-slice +// literal at every call site. +func encodeCommand(args []string) string { + return string(encodeCommands([][]string{args})) +} + // TestEncodeCommand pins the exact RESP2 wire format every command must // use: "*N\r\n$len\r\narg\r\n..." — a fake or real server parses this // framing byte-for-byte, so a drift here breaks every other test silently. diff --git a/routes_media.go b/routes_media.go index f99facd..7e78739 100644 --- a/routes_media.go +++ b/routes_media.go @@ -127,11 +127,18 @@ func (g *Gateway) handleImagesGenerations(w http.ResponseWriter, r *http.Request ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) { g.limiter.recordProviderAttempt(adapter.name(), upstreamModel, resp, attemptErr) }) - // feat: instrument upstream latency — gated on metricsEnabled, same - // reasoning as runUnified's identical wiring (routes_unified.go). - if metricsEnabled(g.cfg) { + // feat: instrument upstream latency — gated on metricsEnabled OR + // admin.stats.latency, same reasoning as runUnified's identical wiring + // (routes_unified.go). + var latSample latencySample + var hasLat bool + if metricsEnabled(g.cfg) || g.limiter.statsLatency { ctx = withLatencyRecorder(ctx, func(sample latencySample) { - g.recordLatency(adapter.name(), upstreamModel, sample) + latSample = sample + hasLat = true + if metricsEnabled(g.cfg) { + g.recordLatency(adapter.name(), upstreamModel, sample) + } }) } if _, err := adapter.imagesGeneration(ctx, sw, req); err != nil { @@ -139,7 +146,7 @@ func (g *Gateway) handleImagesGenerations(w http.ResponseWriter, r *http.Request g.handleAdapterError(sw, err, adapter.name()) return } - g.recordMediaModelUsage(adapter.name(), upstreamModel) + g.recordMediaModelUsage(adapter.name(), upstreamModel, latSample, hasLat) } // handleAudioSpeech implements POST /v1/audio/speech (spec §3): an @@ -196,11 +203,18 @@ func (g *Gateway) handleAudioSpeech(w http.ResponseWriter, r *http.Request, u *u ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) { g.limiter.recordProviderAttempt(adapter.name(), upstreamModel, resp, attemptErr) }) - // feat: instrument upstream latency — gated on metricsEnabled, same - // reasoning as runUnified's identical wiring (routes_unified.go). - if metricsEnabled(g.cfg) { + // feat: instrument upstream latency — gated on metricsEnabled OR + // admin.stats.latency, same reasoning as runUnified's identical wiring + // (routes_unified.go). + var latSample latencySample + var hasLat bool + if metricsEnabled(g.cfg) || g.limiter.statsLatency { ctx = withLatencyRecorder(ctx, func(sample latencySample) { - g.recordLatency(adapter.name(), upstreamModel, sample) + latSample = sample + hasLat = true + if metricsEnabled(g.cfg) { + g.recordLatency(adapter.name(), upstreamModel, sample) + } }) } if _, err := adapter.audioSpeech(ctx, sw, body, "application/json"); err != nil { @@ -208,7 +222,7 @@ func (g *Gateway) handleAudioSpeech(w http.ResponseWriter, r *http.Request, u *u g.handleAdapterError(sw, err, adapter.name()) return } - g.recordMediaModelUsage(adapter.name(), upstreamModel) + g.recordMediaModelUsage(adapter.name(), upstreamModel, latSample, hasLat) } // handleAudioTranscriptions implements POST /v1/audio/transcriptions @@ -304,11 +318,18 @@ func (g *Gateway) handleAudioTranscriptions(w http.ResponseWriter, r *http.Reque ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) { g.limiter.recordProviderAttempt(adapter.name(), upstreamModel, resp, attemptErr) }) - // feat: instrument upstream latency — gated on metricsEnabled, same - // reasoning as runUnified's identical wiring (routes_unified.go). - if metricsEnabled(g.cfg) { + // feat: instrument upstream latency — gated on metricsEnabled OR + // admin.stats.latency, same reasoning as runUnified's identical wiring + // (routes_unified.go). + var latSample latencySample + var hasLat bool + if metricsEnabled(g.cfg) || g.limiter.statsLatency { ctx = withLatencyRecorder(ctx, func(sample latencySample) { - g.recordLatency(adapter.name(), upstreamModel, sample) + latSample = sample + hasLat = true + if metricsEnabled(g.cfg) { + g.recordLatency(adapter.name(), upstreamModel, sample) + } }) } if _, err := adapter.audioTranscription(ctx, sw, uploadBody, uploadContentType); err != nil { @@ -316,7 +337,7 @@ func (g *Gateway) handleAudioTranscriptions(w http.ResponseWriter, r *http.Reque g.handleAdapterError(sw, err, adapter.name()) return } - g.recordMediaModelUsage(adapter.name(), upstreamModel) + g.recordMediaModelUsage(adapter.name(), upstreamModel, latSample, hasLat) } // recordMediaModelUsage writes ONLY the kindModel scope's own request @@ -344,9 +365,21 @@ func (g *Gateway) handleAudioTranscriptions(w http.ResponseWriter, r *http.Reque // the same single-round-trip account already batches for every other // scope, called synchronously here, the same as every other account call // site (routes_unified.go, routes_passthrough.go). -func (g *Gateway) recordMediaModelUsage(providerName, upstreamModel string) { +// +// lat/hasLat (admin-redesign WP-A) carry the same completed-upstream +// latency sample runUnified/handlePassthrough thread into their own +// accountWith call — recordMediaModelUsage passes them through via +// accountExtras.provider/lat/hasLat, exactly like those two routes, so a +// media route's own opt-in latency counters (admin.stats.latency) get +// written from the identical call site, never a second one. +func (g *Gateway) recordMediaModelUsage(providerName, upstreamModel string, lat latencySample, hasLat bool) { canonical := providerModelScopeID(providerName, upstreamModel) - g.limiter.account(withModelScope(nil, canonical), usage{}, 0) + extras := accountExtras{provider: providerName} + if hasLat { + extras.lat = lat + extras.hasLat = true + } + g.limiter.accountWith(withModelScope(nil, canonical), usage{}, 0, extras) } // readAdmittedCapped claims a body-admission slot (acquireBodyAdmission), diff --git a/routes_media_test.go b/routes_media_test.go index f2ddcd3..b7182a1 100644 --- a/routes_media_test.go +++ b/routes_media_test.go @@ -1249,3 +1249,75 @@ func TestHandleAudioTranscriptions_RecordsMediaModelUsage(t *testing.T) { assert.True(t, ok) assert.Equal(t, int64(1), modelReq, "kindModel req counter") } + +// --- admin-redesign WP-A step 5: media routes' own latency threading --- + +// TestHandleImagesGenerations_Latency_WrittenWhenStatsLatencyOn proves +// recordMediaModelUsage threads the completed request's own latSample/ +// hasLat through to accountWith — exactly one provider-level and one +// model-level latency duration bucket must have been incremented, when +// admin.stats.latency is on. +func TestHandleImagesGenerations_Latency_WrittenWhenStatsLatencyOn(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"created":1,"data":[{"url":"https://example.invalid/img.png"}]}`)) + })) + defer srv.Close() + + cfg := newMediaTestConfig(srv.URL, "img-test") + cfg.Admin = &AdminConfig{Enabled: true, Stats: &AdminStatsConfig{Latency: true}} + gw := newMediaTestGateway(t, cfg) + + body := map[string]any{"model": "img-test", "prompt": "a cat"} + b, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, imagesGenerationsPath, bytes.NewReader(b)) + req.Header.Set("Authorization", "Bearer sk-alice") + rec := httptest.NewRecorder() + gw.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + now := time.Now() + var totalProviderLat, totalModelLat int64 + for i := 0; i <= len(latencyBucketBounds); i++ { + if v, ok := gw.limiter.getCounter(kindProvider, "openai", latencyDurationMetric(i), windowDay, now); ok { + totalProviderLat += v + } + if v, ok := gw.limiter.getCounter(kindModel, "openai/img-test", latencyDurationMetric(i), windowDay, now); ok { + totalModelLat += v + } + } + assert.Equal(t, int64(1), totalProviderLat, "exactly one provider-level latency duration bucket must have been incremented") + assert.Equal(t, int64(1), totalModelLat, "exactly one model-level latency duration bucket must have been incremented") +} + +// TestHandleImagesGenerations_Latency_OffByDefault proves no latency +// bucket is ever written when admin.stats.latency (and metrics) are both +// off — the default, additive-and-preserving behavior. +func TestHandleImagesGenerations_Latency_OffByDefault(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"created":1,"data":[{"url":"https://example.invalid/img.png"}]}`)) + })) + defer srv.Close() + + gw := newMediaTestGateway(t, newMediaTestConfig(srv.URL, "img-test")) + + body := map[string]any{"model": "img-test", "prompt": "a cat"} + b, err := json.Marshal(body) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, imagesGenerationsPath, bytes.NewReader(b)) + req.Header.Set("Authorization", "Bearer sk-alice") + rec := httptest.NewRecorder() + gw.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, "body=%s", rec.Body.String()) + + now := time.Now() + for i := 0; i <= len(latencyBucketBounds); i++ { + v, ok := gw.limiter.getCounter(kindProvider, "openai", latencyDurationMetric(i), windowDay, now) + assert.True(t, ok) + assert.Equal(t, int64(0), v, "bucket %d must stay at 0 without admin.stats.latency", i) + } +} diff --git a/routes_passthrough.go b/routes_passthrough.go index 0652297..0ec5281 100644 --- a/routes_passthrough.go +++ b/routes_passthrough.go @@ -1196,6 +1196,9 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u writeLimitViolation(w, violation) return } + // P9 fix (admin dashboard redesign verify round): last-seen only for + // an admitted request — recordLastSeen's own doc comment (limits.go). + g.limiter.recordLastSeen(scopes, g.limiter.now()) // providerName is only ever passed here by ServeHTTP's dispatch, which // already confirmed it is a key of g.adapters before calling in. @@ -1247,14 +1250,23 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u r = r.WithContext(withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) { g.limiter.recordProviderAttempt(providerName, "", resp, attemptErr) })) - // feat: instrument upstream latency — gated on metricsEnabled, same - // reasoning as runUnified's identical wiring (routes_unified.go). No - // model here either, for the identical reason the attemptRecorder - // above passes "": the upstream model lives in the response body, - // read only after this attempt already resolved (comment above). - if metricsEnabled(g.cfg) { + // feat: instrument upstream latency — gated on metricsEnabled OR + // admin.stats.latency (admin-redesign WP-A), same reasoning as + // runUnified's identical wiring (routes_unified.go). No model here + // either, for the identical reason the attemptRecorder above passes + // "": the upstream model lives in the response body, read only after + // this attempt already resolved (comment above) — so accountWith's own + // opt-in latency block below only ever writes this request's PROVIDER + // bucket, never a model one, for native passthrough. + var latSample latencySample + var hasLat bool + if metricsEnabled(g.cfg) || g.limiter.statsLatency { r = r.WithContext(withLatencyRecorder(r.Context(), func(sample latencySample) { - g.recordLatency(providerName, "", sample) + latSample = sample + hasLat = true + if metricsEnabled(g.cfg) { + g.recordLatency(providerName, "", sample) + } })) } @@ -1335,7 +1347,25 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u // withModelScope drops the model scope when respModel is empty (the // upstream reported no model id), so a passthrough reply the gateway // cannot attribute still accounts to user/group/total as before. - g.limiter.account(withModelScope(scopes, canonical), respUsage, cost) + // + // noModelHistograms: true (P13 fix, admin dashboard redesign verify + // round; plan §2(c): passthrough gets "provider histograms only") — + // canonical here is providerName + "/" + an upstream-ECHOED model id, + // not one of this gateway's own configured/catalogued models, so + // keying the opt-in latency and umodel counter families off it would + // grow both families' cardinality by whatever an upstream (or a + // client spoofing its response shape) chooses to report, for entries + // that can never appear in usage/models or usermodel totals anyway — + // both rank off the configured catalog, never an arbitrary passthrough + // id. The base model-scope req/tokin/tokout/cost counters below are + // unaffected; only the two per-model-keyed opt-in families are + // suppressed. + extras := accountExtras{provider: providerName, noModelHistograms: true} + if hasLat { + extras.lat = latSample + extras.hasLat = true + } + g.limiter.accountWith(withModelScope(scopes, canonical), respUsage, cost, extras) } // proxyResult is what proxyUpstream reports back to its caller once it has diff --git a/routes_passthrough_test.go b/routes_passthrough_test.go index ebfa34d..41d1be1 100644 --- a/routes_passthrough_test.go +++ b/routes_passthrough_test.go @@ -3274,3 +3274,135 @@ func TestSSEAccountingBuffer_TailBoundedAndExact(t *testing.T) { t.Fatal("head != first sseAccountingHeadBytes written") } } + +// --- admin-redesign WP-A step 5: passthrough's own provider-level +// latency threading (never a model bucket: the upstream model lives in +// the response body, known only after the attempt already resolved) --- + +// TestHandlePassthrough_Latency_ProviderBucketWrittenWhenStatsLatencyOn +// proves handlePassthrough's own accountWith call carries the completed +// request's latSample/hasLat through accountExtras, writing exactly one +// provider-level latency duration bucket when admin.stats.latency is on. +func TestHandlePassthrough_Latency_ProviderBucketWrittenWhenStatsLatencyOn(t *testing.T) { + const respBody = `{"model":"gpt-native-x","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(respBody)) + })) + defer srv.Close() + + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{ + "openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}, + } + cfg.Groups = map[string]*GroupConfig{"default": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{ + {Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}, + }} + cfg.Admin = &AdminConfig{Enabled: true, Stats: &AdminStatsConfig{Latency: true}} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + gw.limiter.spawn = func(f func()) { f() } + + req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer sk-alice") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + now := time.Now() + var total int64 + for i := 0; i <= len(latencyBucketBounds); i++ { + if v, ok := gw.limiter.getCounter(kindProvider, "openai", latencyDurationMetric(i), windowDay, now); ok { + total += v + } + } + if total != 1 { + t.Errorf("provider-level latency duration buckets summed = %d, want 1", total) + } +} + +// TestHandlePassthrough_ModelLatencyAndUModelSuppressed_ProviderHistogramsOnly +// is P13 (admin dashboard redesign verify round; plan §2(c): passthrough +// gets "provider histograms only"): the model scope routes_passthrough.go +// always attaches for accounting (base req/tokin/tokout/cost) must NOT +// also drive the opt-in per-model latency or user x model (umodel) +// counter families — canonical here is providerName + "/" + an upstream- +// ECHOED model id, unbounded cardinality this gateway does not control, +// and neither family is ever read back for it anyway (both rank off the +// configured catalog, never an arbitrary passthrough id). Provider-level +// latency (the previous test) and the base model-scope req counter are +// BOTH still written — only the two opt-in, per-model-keyed families are +// suppressed. +func TestHandlePassthrough_ModelLatencyAndUModelSuppressed_ProviderHistogramsOnly(t *testing.T) { + const respBody = `{"model":"gpt-native-x","choices":[],"usage":{"prompt_tokens":7,"completion_tokens":3}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(respBody)) + })) + defer srv.Close() + + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{ + "openai": {Type: "openai", BaseURL: srv.URL, APIKey: "sk-up"}, + } + cfg.Groups = map[string]*GroupConfig{"default": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{ + {Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}, + }} + cfg.Admin = &AdminConfig{Enabled: true, Stats: &AdminStatsConfig{Latency: true, UserModel: true}} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + gw.limiter.spawn = func(f func()) { f() } + + req := httptest.NewRequest(http.MethodPost, "/openai/v1/native-endpoint", strings.NewReader(`{}`)) + req.Header.Set("Authorization", "Bearer sk-alice") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + now := time.Now() + canonical := "openai/gpt-native-x" + + var modelLatTotal int64 + for i := 0; i <= len(latencyBucketBounds); i++ { + if v, ok := gw.limiter.getCounter(kindModel, canonical, latencyDurationMetric(i), windowDay, now); ok { + modelLatTotal += v + } + } + if modelLatTotal != 0 { + t.Errorf("model-level latency duration buckets summed = %d, want 0 (provider histograms only)", modelLatTotal) + } + + umodelID := userModelScopeID("alice", canonical) + if v, ok := gw.limiter.getCounter(kindUserModel, umodelID, metricReq, windowDay, now); !ok || v != 0 { + t.Errorf("umodel req = %d (ok=%v), want 0 (passthrough must not write umodel)", v, ok) + } + + // The base model-scope req counter (unaffected by noModelHistograms — + // driven by the per-scope loop, not the latency/umodel blocks) must + // still be written. + if v, ok := gw.limiter.getCounter(kindModel, canonical, metricReq, windowDay, now); !ok || v != 1 { + t.Errorf("model req = %d (ok=%v), want 1 (base model-scope counters are unaffected)", v, ok) + } +} diff --git a/routes_unified.go b/routes_unified.go index 31d060b..a681885 100644 --- a/routes_unified.go +++ b/routes_unified.go @@ -482,6 +482,29 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop } sw.WriteHeader(cached.Status) _, _ = sw.Write(cached.Body) + // Cache-hit counters (Q7, DECISIONS: "cache-hit body parse + // only when cache stats on: accept") — fire-and-forget + // (recordCacheHit's own l.spawn), so this never delays the + // response already written above. saved is computed with a + // no-op warn sink, not g.pricingWarn: this is a re-derivation + // of a price already known when the response was originally + // served and cached, not a fresh unknown-model discovery + // worth logging a second time. + // + // P4 fix (admin dashboard redesign verify round): + // recordCacheHit itself already no-ops when !statsAdmin, but + // cachedUsage(cached.Body) unmarshals the FULL cached body + // first — measured 639µs for a 1 MiB body (defaultCache + // MaxBodyBytes) — so that parse, and the unifiedCostMicros + // derivation it feeds, must never run at all for a cache- + // enabled, admin-disabled deployment, exactly like Q7 already + // requires for a cache-enabled, admin-ENABLED-but-stats-off + // case. Gating here, not just inside recordCacheHit, is what + // actually saves the parse. + if g.limiter.statsAdmin { + saved := unifiedCostMicros(cand.canonical, cand.upstreamModel, cachedUsage(cached.Body), g.cfg.Pricing, g.cfg.ModelMeta, func(string) {}) + g.limiter.recordCacheHit(cand.canonical, saved) + } return } // Set before call() below writes anything — headers must @@ -560,13 +583,28 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop ctx := withAttemptRecorder(r.Context(), func(resp *http.Response, attemptErr error) { g.limiter.recordProviderAttempt(providerName, upstreamModel, resp, attemptErr) }) - // feat: instrument upstream latency — gated on metricsEnabled so a - // deployment with metrics off wires nothing here at all (not even - // the closure allocation), one level up from watchdogBody's own - // per-Read nil check (timeout.go). - if metricsEnabled(g.cfg) { + // feat: instrument upstream latency — gated on metricsEnabled OR + // admin.stats.latency (admin-redesign WP-A), so a deployment with + // BOTH off wires nothing here at all (not even the closure + // allocation), one level up from watchdogBody's own per-Read nil + // check (timeout.go). latSample/hasLat are candidate-LOCAL vars + // (declared fresh each loop iteration, assigned as two separate + // statements — never a tuple assignment mixing a call with another + // expression, the Yaegi trap this file's own failover loop already + // avoids elsewhere): g.recordLatency only fires when metrics are + // actually enabled, but the sample itself is captured whenever + // EITHER condition wired the recorder, so accountWith's own + // opt-in latency counters (below) see it even when Prometheus + // metrics are off. + var latSample latencySample + var hasLat bool + if metricsEnabled(g.cfg) || g.limiter.statsLatency { ctx = withLatencyRecorder(ctx, func(sample latencySample) { - g.recordLatency(providerName, upstreamModel, sample) + latSample = sample + hasLat = true + if metricsEnabled(g.cfg) { + g.recordLatency(providerName, upstreamModel, sample) + } }) } result, callErr := call(cand.adapter, ctx, respWriter, attemptReq) @@ -663,7 +701,45 @@ func (g *Gateway) runMeteredCall(sw *statusTrackingWriter, r *http.Request, scop result.estimated = true } } - g.limiter.account(withModelScope(scopes, cand.canonical), result, unifiedCostMicros(cand.canonical, cand.upstreamModel, result, g.cfg.Pricing, g.cfg.ModelMeta, g.pricingWarn)) + // Q1 fix (DECISIONS; pre-existing defect, WP-A step 7): kindModel's + // own req counter used to count EVERY candidate account() ran for, + // including one that failed before writing anything — a failover + // chain of 3 candidates where only the last succeeded inflated + // that model's served-request count by 2 phantom attempts. Adding + // the model scope only when this candidate actually served + // something (callErr == nil: a genuine 2xx, even a zero-usage + // streaming one) or reported nonzero usage (result.total() > 0: a + // mid-stream failure that still delivered content, billed via the + // estimate branch above) excludes exactly the one remaining case — + // callErr != nil && result.total() == 0, a candidate that failed + // with nothing to show for it — from ever touching kindModel's req + // counter at all. Token/cost counters were never affected by this + // bug: they were already gated on nonzero usage independently. + acctScopes := scopes + if callErr == nil || result.total() > 0 { + acctScopes = withModelScope(scopes, cand.canonical) + } + // P3 fix (admin dashboard redesign verify round): cacheMiss used to + // be set from `cacheable` alone, true for EVERY candidate that + // attempted its own cache lookup and missed — including one that + // went on to fail over. A 3-candidate failover chain where every + // candidate missed its own cache lookup then counted 1 request as + // up to 3 cmiss increments, and a chain where an earlier candidate + // missed-then-failed while a later one HIT double-counted the same + // request as both a miss and a hit. Gated on the SAME condition as + // the model-scope Q1 fix above: only the candidate whose own result + // actually gets served/billed (or that reported nonzero usage + // before failing) ever reports a miss, matching plan §2(d)'s "miss + // -> cacheMiss extras in served candidate's account". + extras := accountExtras{provider: providerName, cacheMiss: cacheable && (callErr == nil || result.total() > 0)} + if i > 0 { + extras.failoverFrom = candidates[i-1].providerName + } + if hasLat { + extras.lat = latSample + extras.hasLat = true + } + g.limiter.accountWith(acctScopes, result, unifiedCostMicros(cand.canonical, cand.upstreamModel, result, g.cfg.Pricing, g.cfg.ModelMeta, g.pricingWarn), extras) if result.estimated { g.logf("%s: usage for model %q logged as estimated (%d prompt tokens derived from request body size, not the provider's reported usage)", logPrefix, cand.canonical, result.prompt) } @@ -857,6 +933,10 @@ func (g *Gateway) admitRequestForRoute(sw *statusTrackingWriter, u *user, grp *g writeLimitViolationEnvelope(sw, violation, envelope) return scopes, false } + // P9 fix (admin dashboard redesign verify round): last-seen is + // recorded ONLY once this request is confirmed admitted — see + // recordLastSeen's own doc comment (limits.go). + g.limiter.recordLastSeen(scopes, g.limiter.now()) return scopes, true } diff --git a/routes_unified_test.go b/routes_unified_test.go index 6fec182..1fe1679 100644 --- a/routes_unified_test.go +++ b/routes_unified_test.go @@ -2790,3 +2790,341 @@ func TestHandleChat_UnderNewUnifiedCap_InlineBase64Image_StillWorks(t *testing.T t.Error("upstream did not receive the full inline image payload — the cap silently truncated a request under its own limit") } } + +// --- admin-redesign WP-A step 7: kindModel req must not count a failed +// failover candidate (Q1, DECISIONS; pre-existing defect fixed here) --- + +// TestHandleChat_Failover_ModelReqCounter_NotChargedToFailedCandidate is +// the regression test for the pre-existing defect DECISIONS Q1 fixes: +// before this fix, account() ran once per candidate — including a +// candidate that failed before writing anything — and kindModel's own req +// counter was written UNCONDITIONALLY for every one of them. alpha fails +// with a pre-send 500 (zero usage, callErr != nil): its own "alpha/shared" +// model-scope req counter must stay at 0. beta succeeds: its own +// "beta/shared" counter must read exactly 1. +func TestHandleChat_Failover_ModelReqCounter_NotChargedToFailedCandidate(t *testing.T) { + alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`) + defer alphaSrv.Close() + betaSrv := jsonServer(http.StatusOK, successRespBody) + defer betaSrv.Close() + + cfg := twoProviderFailoverConfig(alphaSrv, betaSrv) + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + + body := map[string]any{"model": "shared", "messages": []any{}} + req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + now := time.Now() + alphaModelReq, ok := gw.limiter.getCounter(kindModel, "alpha/shared", metricReq, windowDay, now) + if !ok { + t.Fatal("getCounter(alpha/shared) ok = false") + } + if alphaModelReq != 0 { + t.Errorf("alpha/shared model req = %d, want 0 (a candidate that failed with zero usage must never touch kindModel's req counter)", alphaModelReq) + } + + betaModelReq, ok := gw.limiter.getCounter(kindModel, "beta/shared", metricReq, windowDay, now) + if !ok { + t.Fatal("getCounter(beta/shared) ok = false") + } + if betaModelReq != 1 { + t.Errorf("beta/shared model req = %d, want 1", betaModelReq) + } +} + +// TestHandleChat_ZeroUsageStreamingSuccess_StillCountsModelReq proves the +// step-7 fix's narrow exclusion is exactly callErr!=nil&&total()==0 — a +// SUCCESSFUL streaming response with zero reported usage (callErr == nil) +// must still count kindModel's req, since it was genuinely served. +func TestHandleChat_ZeroUsageStreamingSuccess_StillCountsModelReq(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("data: {\"id\":\"x\",\"object\":\"chat.completion.chunk\",\"model\":\"gpt-test\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hi\"},\"finish_reason\":null}]}\n\n")) + _, _ = w.Write([]byte("data: [DONE]\n\n")) + })) + defer srv.Close() + + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{ + "openai": {Type: "openai", BaseURL: srv.URL, APIKey: "k", Models: []string{"gpt-test"}}, + } + cfg.Groups = map[string]*GroupConfig{"default": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice"}}} + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + + body := map[string]any{"model": "gpt-test", "stream": true, "messages": []any{}} + req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + modelReq, ok := gw.limiter.getCounter(kindModel, "openai/gpt-test", metricReq, windowDay, time.Now()) + if !ok || modelReq != 1 { + t.Errorf("openai/gpt-test model req = %d (ok=%v), want 1 (a genuinely served, zero-usage stream still counts)", modelReq, ok) + } +} + +// --- admin-redesign WP-A step 6: cache-hit/miss counters end to end --- + +// TestHandleChat_CacheHitMiss_RecordsCmissOnMissAndChitCsaveOnHit drives +// two identical chat-completion requests through a real Gateway with +// caching AND admin stats enabled (newBehavioralRedisServer, the same +// fake-but-behaviorally-correct Redis TestHandleChat_CacheHitMiss_ +// EndToEnd_CountersAndHeaders already uses): the first (miss) must record +// total/model cmiss in the SAME synchronous account batch; the second +// (hit) must record total/model chit and csave via the async +// recordCacheHit path — spawn is forced synchronous so both land +// deterministically before this test reads them back. +func TestHandleChat_CacheHitMiss_RecordsCmissOnMissAndChitCsaveOnHit(t *testing.T) { + const respBody = `{"id":"chatcmpl-1","object":"chat.completion","model":"gpt-test","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(respBody)) + })) + defer srv.Close() + + redisLn := newBehavioralRedisServer(t) + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{ + "openai": {Type: "openai", BaseURL: srv.URL, APIKey: "k", Models: []string{"gpt-test"}}, + } + cfg.Groups = map[string]*GroupConfig{"default": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}} + cfg.Redis = &RedisConfig{Address: redisLn.Addr().String()} + cfg.Cache = CacheConfig{Enabled: true, TTL: "1m"} + cfg.Admin = &AdminConfig{Enabled: true} + // Explicit price override — "gpt-test" is not a real model id, so + // leaving this unset would price it 0 (unpriced) and make the csave + // assertion below meaningless. + cfg.Pricing = map[string]*ModelPricing{"gpt-test": {InputPerM: 1, OutputPerM: 2}} + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + gw.limiter.spawn = func(f func()) { f() } // land recordProviderAttempt/recordCacheHit synchronously + + body := map[string]any{"model": "gpt-test", "messages": []any{map[string]any{"role": "user", "content": "hi"}}} + + req1 := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body) + rec1 := httptest.NewRecorder() + h.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusOK || rec1.Header().Get("X-Llmgw-Cache") != "miss" { + t.Fatalf("first request: status=%d cache-header=%q, want 200/miss, body=%s", rec1.Code, rec1.Header().Get("X-Llmgw-Cache"), rec1.Body.String()) + } + + now := time.Now() + totalCmiss, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricCmiss, windowDay, now) + if !ok || totalCmiss != 1 { + t.Errorf("after miss: total cmiss/day = %d (ok=%v), want 1", totalCmiss, ok) + } + modelCmiss, ok := gw.limiter.getCounter(kindModel, "openai/gpt-test", metricCmiss, windowDay, now) + if !ok || modelCmiss != 1 { + t.Errorf("after miss: model cmiss/day = %d (ok=%v), want 1", modelCmiss, ok) + } + + req2 := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body) + rec2 := httptest.NewRecorder() + h.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK || rec2.Header().Get("X-Llmgw-Cache") != "hit" { + t.Fatalf("second request: status=%d cache-header=%q, want 200/hit, body=%s", rec2.Code, rec2.Header().Get("X-Llmgw-Cache"), rec2.Body.String()) + } + if rec2.Body.String() != rec1.Body.String() { + t.Errorf("hit body = %q, want identical to the miss's own stored body %q", rec2.Body.String(), rec1.Body.String()) + } + + totalChit, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricChit, windowDay, now) + if !ok || totalChit != 1 { + t.Errorf("after hit: total chit/day = %d (ok=%v), want 1", totalChit, ok) + } + modelChit, ok := gw.limiter.getCounter(kindModel, "openai/gpt-test", metricChit, windowDay, now) + if !ok || modelChit != 1 { + t.Errorf("after hit: model chit/day = %d (ok=%v), want 1", modelChit, ok) + } + totalCsave, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricCsave, windowDay, now) + if !ok || totalCsave <= 0 { + t.Errorf("after hit: total csave/day = %d (ok=%v), want > 0 (gpt-test has a built-in price)", totalCsave, ok) + } +} + +// TestHandleChat_CacheMissFailover_CountsExactlyOneCmiss is P3 (admin +// dashboard redesign verify round): cacheMiss extras used to be set from +// `cacheable` alone — true for EVERY candidate that attempted its own +// cache lookup and missed, including one that went on to fail over. A +// 2-candidate failover chain where alpha misses its own cache lookup and +// then fails, and beta then serves, used to count 1 end-user request as 2 +// total/day cmiss increments. Gated on the SAME "served or reported +// usage" condition as the pre-existing Q1 model-scope fix (routes_ +// unified.go), only the candidate whose result actually gets served/ +// billed reports a miss now. +func TestHandleChat_CacheMissFailover_CountsExactlyOneCmiss(t *testing.T) { + alphaSrv := jsonServer(http.StatusInternalServerError, `{"error":{"message":"down","type":"server_error"}}`) + defer alphaSrv.Close() + betaSrv := jsonServer(http.StatusOK, successRespBody) + defer betaSrv.Close() + + cfg := twoProviderFailoverConfig(alphaSrv, betaSrv) + redisLn := newBehavioralRedisServer(t) + cfg.Redis = &RedisConfig{Address: redisLn.Addr().String()} + cfg.Cache = CacheConfig{Enabled: true, TTL: "1m"} + cfg.Admin = &AdminConfig{Enabled: true} + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + gw.limiter.spawn = func(f func()) { f() } + + body := map[string]any{"model": "shared", "messages": []any{map[string]any{"role": "user", "content": "hi"}}} + req := newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (beta must serve after alpha fails), body=%s", rec.Code, rec.Body.String()) + } + + now := time.Now() + cmiss, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricCmiss, windowDay, now) + if !ok || cmiss != 1 { + t.Errorf("total cmiss/day = %d (ok=%v), want exactly 1 for one request with failover (never one per candidate that missed its own cache lookup)", cmiss, ok) + } + fover, ok := gw.limiter.getCounter(kindProvider, "alpha", metricFover, windowDay, now) + if !ok || fover != 1 { + t.Errorf("fover(alpha)/day = %d (ok=%v), want 1", fover, ok) + } +} + +// TestCacheHit_BodyParseSkippedWhenStatsAdminOff is P4 (admin dashboard +// redesign verify round, hot path): recordCacheHit itself already no-ops +// when !statsAdmin, but cachedUsage(cached.Body) — a full JSON unmarshal +// of the cached response body — used to run UNCONDITIONALLY before that +// check, so a cache-enabled, admin-DISABLED deployment paid the parse +// cost on every single hit for nothing (measured elsewhere at 639µs for a +// 1 MiB body). Measured here via testing.AllocsPerRun against two real +// Gateways (cache configured, admin on vs admin off) serving repeated +// hits for the SAME large (256 KiB) cached body: admin off must allocate +// dramatically less per hit than admin on, whose own cachedUsage+ +// unifiedCostMicros parse of that body — plus recordCacheHit's own +// synchronous (spawn forced sync for determinism) counterStore write — +// dominates its count. +func TestCacheHit_BodyParseSkippedWhenStatsAdminOff(t *testing.T) { + const bodySize = 2 << 20 // 2 MiB content, comfortably past BenchmarkV6_CachedUsage's own 1 MiB/639µs figure + + buildHitFunc := func(t *testing.T, adminOn bool) func() { + t.Helper() + content := strings.Repeat("a", bodySize) + respBody := `{"id":"x","object":"chat.completion","model":"gpt-test","choices":[{"index":0,"message":{"role":"assistant","content":"` + content + `"}}],"usage":{"prompt_tokens":10,"completion_tokens":5}}` + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(respBody)) + })) + t.Cleanup(srv.Close) + + redisLn := newBehavioralRedisServer(t) + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{ + "openai": {Type: "openai", BaseURL: srv.URL, APIKey: "k", Models: []string{"gpt-test"}}, + } + cfg.Groups = map[string]*GroupConfig{"default": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{{Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}}} + cfg.Redis = &RedisConfig{Address: redisLn.Addr().String()} + // MaxBodyBytes raised to maxCacheMaxBodyBytes: the default + // (defaultCacheMaxBodyBytes, cache.go, exactly 1 MiB) would refuse + // to cache this test's own >1 MiB body at all, turning every + // "hit" attempt below back into a miss. + cfg.Cache = CacheConfig{Enabled: true, TTL: "1m", MaxBodyBytes: maxCacheMaxBodyBytes} + if adminOn { + cfg.Admin = &AdminConfig{Enabled: true} + } + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + gw.limiter.spawn = func(f func()) { f() } + + body := map[string]any{"model": "gpt-test", "messages": []any{map[string]any{"role": "user", "content": "hi"}}} + rec := httptest.NewRecorder() + h.ServeHTTP(rec, newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)) + if rec.Code != http.StatusOK || rec.Header().Get("X-Llmgw-Cache") != "miss" { + t.Fatalf("warm-up: status=%d cache=%q, want 200/miss", rec.Code, rec.Header().Get("X-Llmgw-Cache")) + } + + return func() { + hrec := httptest.NewRecorder() + h.ServeHTTP(hrec, newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body)) + if hrec.Code != http.StatusOK || hrec.Header().Get("X-Llmgw-Cache") != "hit" { + t.Fatalf("status=%d cache=%q, want 200/hit", hrec.Code, hrec.Header().Get("X-Llmgw-Cache")) + } + } + } + + hitOn := buildHitFunc(t, true) + hitOff := buildHitFunc(t, false) + + // encoding/json's decoder skips a field cachedUsage's target struct + // does not name (the cached body's own "choices[0].message.content") + // WITHOUT materializing it into a Go value — so the parse's own cost + // shows up as CPU time spent scanning every byte, not as a + // proportionally large allocation count or byte total (the actual + // perf finding this fix addresses was itself time-based: "1 MiB 639 + // µs per hit"). NsPerOp is therefore the metric that actually reflects + // this fix, not AllocsPerRun/AllocedBytesPerOp. + resultOn := testing.Benchmark(func(b *testing.B) { + for i := 0; i < b.N; i++ { + hitOn() + } + }) + resultOff := testing.Benchmark(func(b *testing.B) { + for i := 0; i < b.N; i++ { + hitOff() + } + }) + + nsOn := resultOn.NsPerOp() + nsOff := resultOff.NsPerOp() + t.Logf("cache-hit ns/op for a %d MiB cached body: admin on = %d, admin off = %d", bodySize/(1<<20), nsOn, nsOff) + if nsOn < nsOff+nsOff/3 { + t.Errorf("ns/op with admin on (%d) is not meaningfully slower than admin off (%d) — the cached body's own JSON parse (cachedUsage) must be skipped entirely when statsAdmin is off, not merely cheap", nsOn, nsOff) + } +} diff --git a/stats_read.go b/stats_read.go new file mode 100644 index 0000000..c73eb28 --- /dev/null +++ b/stats_read.go @@ -0,0 +1,1130 @@ +package traefikllmgateway + +import ( + "encoding/json" + "net/http" + "sort" + "strconv" + "strings" + "time" +) + +// adminUsageSeriesPath, adminUsageTotalsPath and adminPerformancePath are +// GET /admin/api/usage/series, GET /admin/api/usage/totals and GET +// /admin/api/performance's routes (admin dashboard redesign, WP-B) — +// defined here, alongside the rest of these features' own constants, +// mirroring adminEventsPath's own precedent (events.go). +const ( + adminUsageSeriesPath = "/admin/api/usage/series" + adminUsageTotalsPath = "/admin/api/usage/totals" + adminPerformancePath = "/admin/api/performance" +) + +// adminMaxKeysPerRequest is the hard cap on how many counterStore keys any +// single stats_read.go endpoint may read in one request (DECISIONS Q8): a +// caller whose scope/span/offset/metric combination would read more than +// this many keys gets 400 "range too large; narrow span or filter" +// instead of an unbounded pipeline against the shared store. +const adminMaxKeysPerRequest = 64000 + +// adminSeriesMaxScopes bounds GET /admin/api/usage/series' repeated +// "scope" query parameter (plan §1.3(iv): "1..100"). +const adminSeriesMaxScopes = 100 + +// parseStatsOffset parses an optional "offset" query parameter shared by +// GET /admin/api/usage/models (admin.go), .../usage/series, .../usage/ +// totals and .../performance: empty means 0 (the current, most-recent +// span — every endpoint's own pre-offset behavior), otherwise an integer +// between 0 and window's historyMaxSpan minus the already-validated span, +// inclusive — span+offset never exceeds what that window's TTL could ever +// have kept alive (historyMaxSpan's own doc comment, admin.go). ok is +// false for anything else. +func parseStatsOffset(raw, window string, span int) (offset int, ok bool) { + if raw == "" { + return 0, true + } + max := historyMaxSpan(window) - span + n, err := strconv.Atoi(raw) + if err != nil || n < 0 || n > max { + return 0, false + } + return n, true +} + +// readSpanTotals reads, for every id in ids (all sharing kind), each +// metric in metrics summed over span buckets ending offset window-steps +// back from now, keyed by id then metric name. P6/P12 fix (admin +// dashboard redesign verify round): this used to build its own single +// unbounded storeGetMulti pipeline for the whole batch; it now delegates +// to limits.go's spanTotalsMulti — the ONE production span-sum reader +// admin.go's chunkedModelSpanTotals/chunkedModelSpanTotalsMulti also +// call — which chunks every read at usageModelsChunkKeys keys per round +// trip. ok is false on any store read failure or a fail-open outage in +// progress (configuredStoreDown), exactly like every other admin stats +// reader in this package — spanTotalsMulti itself already applies that +// check per chunk. +func (g *Gateway) readSpanTotals(kind string, ids, metrics []string, window string, now time.Time, span, offset int) (map[string]map[string]int64, bool) { + out := make(map[string]map[string]int64, len(ids)) + if len(ids) == 0 || len(metrics) == 0 { + return out, true + } + totalsByMetric, ok := g.limiter.spanTotalsMulti(kind, ids, metrics, window, now, span, offset) + if !ok { + return nil, false + } + for i, id := range ids { + m := make(map[string]int64, len(metrics)) + for mi, metric := range metrics { + m[metric] = totalsByMetric[mi][i] + } + out[id] = m + } + return out, true +} + +// --------------------------------------------------------------------- +// GET /admin/api/usage/series +// --------------------------------------------------------------------- + +// adminSeriesView is one requested scope's bucketed series in GET +// /admin/api/usage/series. +type adminSeriesView struct { + Scope string `json:"scope"` + Points []int64 `json:"points"` +} + +// adminSeriesResponse is the full body of GET /admin/api/usage/series. +type adminSeriesResponse struct { + Metric string `json:"metric"` + Window string `json:"window"` + Buckets []string `json:"buckets"` // oldest first + Series []adminSeriesView `json:"series"` + Unknown []string `json:"unknown"` // never nil + Span int `json:"span"` + Offset int `json:"offset"` +} + +// parseSeriesScope parses one GET /admin/api/usage/series "scope" value: +// "total", "user:{id}", "group:{id}", "model:{canonical}", +// "provider:{name}" or "provmodel:{canonical}". ok is false for anything +// else. +func parseSeriesScope(raw string) (kind, id string, ok bool) { + if raw == totalScopeKind { + return totalScopeKind, totalScopeID, true + } + prefix, rest, found := strings.Cut(raw, ":") + if !found || rest == "" { + return "", "", false + } + switch prefix { + case "user", "group": + return prefix, rest, true + case "model": + return kindModel, rest, true + case "provider": + return kindProvider, rest, true + case "provmodel": + return kindProviderModel, rest, true + default: + return "", "", false + } +} + +// seriesScopeExists extends scopeExists (admin.go) to also validate a +// kindProvider or kindProviderModel scope against the live registry — +// the two additional scope kinds GET /admin/api/usage/series accepts +// that GET /admin/api/usage/history (scopeExists' only other caller) +// never did. +func (g *Gateway) seriesScopeExists(kind, id string) bool { + switch kind { + case kindProvider: + for _, s := range g.registry.snapshot() { + if s.name == id { + return true + } + } + return false + case kindProviderModel: + return g.scopeExists(kindModel, id) + default: + return g.scopeExists(kind, id) + } +} + +// seriesValidMetric reports whether metric is a valid reading for +// scopeKind at window — plan §1.3(iv)'s metric x scope-kind matrix. +func seriesValidMetric(scopeKind, metric, window string) bool { + hourOrDay := window == windowHour || window == windowDay + switch scopeKind { + case "user", "group", totalScopeKind: + switch metric { + case metricReq, metricTokIn, metricTokOut, metricCost: + return true + case metricRej: + return hourOrDay + } + if scopeKind == totalScopeKind { + switch metric { + case metricChit, metricCmiss, metricCsave, metricR402: + return hourOrDay + } + } + return false + case kindModel: + switch metric { + case metricReq, metricTokIn, metricTokOut, metricCost: + return true + case metricChit, metricCsave, metricR402: + return window == windowDay + } + return false + case kindProvider: + switch metric { + case metricProvAttempt, metricProvFail, metricProvTimeout, metricFover: + return hourOrDay + } + return false + case kindProviderModel: + switch metric { + case metricProvAttempt, metricProvFail: + return hourOrDay + } + return false + default: + return false + } +} + +// serveAdminUsageSeries writes GET /admin/api/usage/series: one or more +// scopes' bucketed history for a single metric/window/span/offset, in one +// batched store read. Every parameter is validated before the store is +// touched (400 for anything malformed or out of range); an unknown scope +// id is skipped and listed in Unknown rather than failing the whole +// request, mirroring the free-models ranking's own "skip, don't fail" +// convention for an individual bad element within an otherwise valid +// request. +func (g *Gateway) serveAdminUsageSeries(sw *statusTrackingWriter, r *http.Request) { + q := r.URL.Query() + + rawScopes := q["scope"] + if len(rawScopes) == 0 { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "at least one scope is required") + return + } + if len(rawScopes) > adminSeriesMaxScopes { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "too many scopes") + return + } + + window := q.Get("window") + if !validHistoryWindow(window) { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window") + return + } + metric := q.Get("metric") + if !validHistoryMetric(metric) && !isKnownStatsMetric(metric) { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown metric") + return + } + span, ok := parseHistorySpan(q.Get("span"), window) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span") + return + } + offset, ok := parseStatsOffset(q.Get("offset"), window, span) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset") + return + } + + type parsedScope struct { + raw, kind, id string + } + parsed := make([]parsedScope, 0, len(rawScopes)) + unknown := []string{} + for _, raw := range rawScopes { + kind, id, ok := parseSeriesScope(raw) + if !ok { + unknown = append(unknown, raw) + continue + } + if !seriesValidMetric(kind, metric, window) { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown metric for scope kind") + return + } + if kind != totalScopeKind && !g.seriesScopeExists(kind, id) { + unknown = append(unknown, raw) + continue + } + parsed = append(parsed, parsedScope{raw: raw, kind: kind, id: id}) + } + + if len(parsed)*span > adminMaxKeysPerRequest { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter") + return + } + + now := g.limiter.now() + var buckets []string + series := make([]adminSeriesView, 0, len(parsed)) + allKeys := make([]string, 0, len(parsed)*span) + for _, p := range parsed { + keys, bkts := historyBucketKeysAt(p.kind, p.id, metric, window, now, span, offset) + if buckets == nil { + buckets = bkts + } + allKeys = append(allKeys, keys...) + } + + // P6 fix (admin dashboard redesign verify round): chunked at + // usageModelsChunkKeys keys per round trip (chunkedGetMulti, limits.go) + // instead of one unbounded pipeline covering every requested scope's + // whole span at once — a 100-scope request at span=48 used to build a + // single 4,800-key pipeline in one call. + vals, storeOK := g.limiter.chunkedGetMulti(allKeys, usageModelsChunkKeys) + if !storeOK || len(vals) != len(allKeys) { + writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable") + return + } + for i, p := range parsed { + points := append([]int64(nil), vals[i*span:i*span+span]...) + series = append(series, adminSeriesView{Scope: p.raw, Points: points}) + } + if buckets == nil { + buckets = []string{} + } + + setAdminJSONHeaders(sw) + _ = json.NewEncoder(sw).Encode(adminSeriesResponse{ + Metric: metric, Window: window, Span: span, Offset: offset, + Buckets: buckets, Series: series, Unknown: unknown, + }) +} + +// isKnownStatsMetric reports whether metric is one of the admin-redesign +// counter families' metric names (fover/timeout/r402/chit/cmiss/csave/ +// attempt/fail) — validHistoryMetric (admin.go) only ever knew the four +// original req/tokin/tokout/cost metrics, so GET /admin/api/usage/series +// and GET /admin/api/usage/totals — the two endpoints that also read +// these newer families — check both. +func isKnownStatsMetric(metric string) bool { + switch metric { + case metricRej, metricProvAttempt, metricProvFail, metricProvTimeout, metricFover, + metricR402, metricChit, metricCmiss, metricCsave: + return true + default: + return false + } +} + +// --------------------------------------------------------------------- +// GET /admin/api/usage/totals +// --------------------------------------------------------------------- + +// adminTotalsRow is one entity's metric values in GET +// /admin/api/usage/totals. ID's shape depends on Kind: a plain name for +// user/group, a canonical "provider/model" id for usermodel, a user name +// for modeluser, or "mcp/{target}/{caller}"/"agent/{target}/{caller}" +// for targetcaller (targetCallerScopeID's own format, limits.go). +type adminTotalsRow struct { + Values map[string]int64 `json:"values"` + ID string `json:"id"` +} + +// adminTotalsResponse is the full body of GET /admin/api/usage/totals. +type adminTotalsResponse struct { + Kind string `json:"kind"` + Window string `json:"window"` + Metrics []string `json:"metrics"` + Rows []adminTotalsRow `json:"rows"` + Span int `json:"span"` + Offset int `json:"offset"` + Truncated bool `json:"truncated,omitempty"` +} + +const ( + adminTotalsDefaultLimit = 200 + adminTotalsMaxLimit = 1000 +) + +// totalsKindMetrics enumerates every metric allowed for kind, in a fixed +// order — the default `metrics` list when a request omits that query +// parameter, and the set a caller's own comma list is validated against +// (plan §1.3(v): "usermodel/modeluser/targetcaller: day/month only; +// req/tokin/tokout/cost (targetcaller req only)"). +func totalsKindMetrics(kind string) []string { + switch kind { + case "user", "group", "usermodel", "modeluser": + return []string{metricReq, metricTokIn, metricTokOut, metricCost} + case "targetcaller": + return []string{metricReq} + default: + return nil + } +} + +// totalsValidWindow reports whether window is valid for kind: usermodel/ +// modeluser/targetcaller accept only day/month (plan §1.3(v)); user/group +// accept any of hour/day/month (validHistoryWindow, admin.go). +func totalsValidWindow(kind, window string) bool { + switch kind { + case "usermodel", "modeluser", "targetcaller": + return window == windowDay || window == windowMonth + default: + return validHistoryWindow(window) + } +} + +// parseTotalsMetrics parses GET /admin/api/usage/totals' optional +// "metrics" query parameter: empty means every metric totalsKindMetrics +// allows for kind, in that fixed order; otherwise a comma-separated list, +// each entry one of those allowed metrics, no duplicates. ok is false for +// an unknown metric, a duplicate, or an empty list. +func parseTotalsMetrics(raw, kind string) (metrics []string, ok bool) { + allowed := totalsKindMetrics(kind) + if raw == "" { + return allowed, true + } + allowedSet := make(map[string]bool, len(allowed)) + for _, m := range allowed { + allowedSet[m] = true + } + parts := strings.Split(raw, ",") + seen := make(map[string]bool, len(parts)) + out := make([]string, 0, len(parts)) + for _, p := range parts { + if !allowedSet[p] || seen[p] { + return nil, false + } + seen[p] = true + out = append(out, p) + } + return out, true +} + +// parseTotalsLimit parses GET /admin/api/usage/totals' optional "limit" +// query parameter: empty means adminTotalsDefaultLimit, otherwise an +// integer between 1 and adminTotalsMaxLimit inclusive. +func parseTotalsLimit(raw string) (limit int, ok bool) { + if raw == "" { + return adminTotalsDefaultLimit, true + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > adminTotalsMaxLimit { + return 0, false + } + return n, true +} + +// parseTargetRef parses a "target" query value ("mcp/{name}" or +// "agent/{name}") into its kind ("mcp"|"agent") and name. +func parseTargetRef(raw string) (kind, name string, ok bool) { + k, n, found := strings.Cut(raw, "/") + if !found || n == "" { + return "", "", false + } + if k != "mcp" && k != "agent" { + return "", "", false + } + return k, n, true +} + +// stringSliceContains reports whether s contains v. +func stringSliceContains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +// totalsCandidate is one row-to-be, before the drop-all-zero/sort/limit +// pass buildTotalsRows applies. +type totalsCandidate struct { + values map[string]int64 + id string +} + +// buildTotalsRows drops every candidate whose every requested metric is +// zero, sorts the rest by metrics[0] descending then id ascending (plan +// §1.3(v): "Drop all-zero rows; sort by first metric desc then id"), and +// truncates to limit, reporting whether it did. +func buildTotalsRows(candidates []totalsCandidate, metrics []string, limit int) ([]adminTotalsRow, bool) { + rows := make([]adminTotalsRow, 0, len(candidates)) + for _, c := range candidates { + allZero := true + for _, m := range metrics { + if c.values[m] != 0 { + allZero = false + break + } + } + if allZero { + continue + } + rows = append(rows, adminTotalsRow{ID: c.id, Values: c.values}) + } + first := metrics[0] + sort.Slice(rows, func(i, j int) bool { + vi, vj := rows[i].Values[first], rows[j].Values[first] + if vi != vj { + return vi > vj + } + return rows[i].ID < rows[j].ID + }) + truncated := len(rows) > limit + if truncated { + rows = rows[:limit] + } + return rows, truncated +} + +// serveAdminUsageTotals writes GET /admin/api/usage/totals: ranked +// per-entity metric totals for one of five kinds (plan §1.3(v)). +// usermodel/modeluser additionally 404 when admin.stats.userModel is off +// (parity with GET /admin/api/usage/models' own detail extras). +func (g *Gateway) serveAdminUsageTotals(sw *statusTrackingWriter, r *http.Request) { + q := r.URL.Query() + + kind := q.Get("kind") + switch kind { + case "user", "group", "usermodel", "modeluser", "targetcaller": + default: + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown kind") + return + } + + window := q.Get("window") + if window == "" { + window = windowDay + } + if !totalsValidWindow(kind, window) { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window") + return + } + span, ok := parseUsageModelsSpan(q.Get("span"), window) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span") + return + } + offset, ok := parseStatsOffset(q.Get("offset"), window, span) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset") + return + } + metrics, ok := parseTotalsMetrics(q.Get("metrics"), kind) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid metrics") + return + } + limit, ok := parseTotalsLimit(q.Get("limit")) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid limit") + return + } + + userParam := q.Get("user") + if kind == "usermodel" && userParam == "" { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "user is required for kind=usermodel") + return + } + modelParam := q.Get("model") + if kind == "modeluser" && modelParam == "" { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "model is required for kind=modeluser") + return + } + var targetKind, targetName string + if raw := q.Get("target"); kind == "targetcaller" && raw != "" { + targetKind, targetName, ok = parseTargetRef(raw) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid target") + return + } + } + + // Feature gate checked LAST, after every parameter is already known + // valid (plan §1.3's intro paragraph: "validate params first... 404 + // ...statistics are not enabled" — a malformed request must always + // answer 400 for the thing that is actually wrong with it, never a + // 404 that reads as "this whole kind doesn't exist" when the real + // problem is a missing/invalid parameter the caller can fix on its + // own regardless of whether the feature is enabled). + if (kind == "usermodel" || kind == "modeluser") && !g.buildAdminFeatures().UserModelStats { + writeOAIError(sw, http.StatusNotFound, "invalid_request_error", "per-user-model statistics are not enabled (admin.stats.userModel)") + return + } + + now := g.limiter.now() + userSummaries, groupSummaries := g.auth.snapshot() + + var candidates []totalsCandidate + var storeOK bool + + switch kind { + case "user": + ids := make([]string, 0, len(userSummaries)) + for _, us := range userSummaries { + if grp := q.Get("group"); grp != "" && !stringSliceContains(us.groups, grp) { + continue + } + ids = append(ids, us.name) + } + if len(ids)*len(metrics)*span > adminMaxKeysPerRequest { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter") + return + } + var raw map[string]map[string]int64 + raw, storeOK = g.readSpanTotals("user", ids, metrics, window, now, span, offset) + candidates = make([]totalsCandidate, len(ids)) + for i, id := range ids { + candidates[i] = totalsCandidate{id: id, values: raw[id]} + } + + case "group": + ids := make([]string, len(groupSummaries)) + for i, gs := range groupSummaries { + ids[i] = gs.name + } + if len(ids)*len(metrics)*span > adminMaxKeysPerRequest { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter") + return + } + var raw map[string]map[string]int64 + raw, storeOK = g.readSpanTotals("group", ids, metrics, window, now, span, offset) + candidates = make([]totalsCandidate, len(ids)) + for i, id := range ids { + candidates[i] = totalsCandidate{id: id, values: raw[id]} + } + + case "usermodel": + // Cheap phase (plan §1.3(v)): narrow the full catalog down to + // models with ANY recent usage at all, via a single-metric read + // over covering month buckets, before ever touching a per-user + // umodel counter — the catalog can run to hundreds of models, + // while a real user typically used only a handful of them. + modelIDs := g.catalogModelScopeIDs() + coverSpan := historyMaxSpan(windowMonth) + if len(modelIDs)*coverSpan > adminMaxKeysPerRequest { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter") + return + } + cheap, cheapOK := g.readSpanTotals(kindModel, modelIDs, []string{metricReq}, windowMonth, now, coverSpan, 0) + if !cheapOK { + writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable") + return + } + var nonzero []string + for _, id := range modelIDs { + if cheap[id][metricReq] != 0 { + nonzero = append(nonzero, id) + } + } + umodelIDs := make([]string, len(nonzero)) + for i, canonical := range nonzero { + umodelIDs[i] = userModelScopeID(userParam, canonical) + } + if len(umodelIDs)*len(metrics)*span > adminMaxKeysPerRequest { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter") + return + } + var raw map[string]map[string]int64 + raw, storeOK = g.readSpanTotals(kindUserModel, umodelIDs, metrics, window, now, span, offset) + candidates = make([]totalsCandidate, len(nonzero)) + for i, canonical := range nonzero { + candidates[i] = totalsCandidate{id: canonical, values: raw[umodelIDs[i]]} + } + + case "modeluser": + umodelIDs := make([]string, len(userSummaries)) + for i, us := range userSummaries { + umodelIDs[i] = userModelScopeID(us.name, modelParam) + } + if len(umodelIDs)*len(metrics)*span > adminMaxKeysPerRequest { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter") + return + } + var raw map[string]map[string]int64 + raw, storeOK = g.readSpanTotals(kindUserModel, umodelIDs, metrics, window, now, span, offset) + candidates = make([]totalsCandidate, len(userSummaries)) + for i, us := range userSummaries { + candidates[i] = totalsCandidate{id: us.name, values: raw[umodelIDs[i]]} + } + + case "targetcaller": + type targetRef struct{ kind, name string } + var targets []targetRef + if targetName != "" { + targets = []targetRef{{targetKind, targetName}} + } else { + for _, name := range sortedMCPServerNames(g.cfg.MCPServers) { + targets = append(targets, targetRef{"mcp", name}) + } + for _, name := range sortedAgentNames(g.cfg.Agents) { + targets = append(targets, targetRef{"agent", name}) + } + } + ids := make([]string, 0, len(targets)*len(userSummaries)) + for _, t := range targets { + for _, us := range userSummaries { + ids = append(ids, targetCallerScopeID(t.kind, t.name, us.name)) + } + } + if len(ids)*len(metrics)*span > adminMaxKeysPerRequest { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter") + return + } + var raw map[string]map[string]int64 + raw, storeOK = g.readSpanTotals(kindTargetCaller, ids, metrics, window, now, span, offset) + candidates = make([]totalsCandidate, len(ids)) + for i, id := range ids { + candidates[i] = totalsCandidate{id: id, values: raw[id]} + } + } + + if !storeOK { + writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable") + return + } + + rows, truncated := buildTotalsRows(candidates, metrics, limit) + setAdminJSONHeaders(sw) + _ = json.NewEncoder(sw).Encode(adminTotalsResponse{ + Kind: kind, Window: window, Metrics: metrics, Rows: rows, + Span: span, Offset: offset, Truncated: truncated, + }) +} + +// --------------------------------------------------------------------- +// GET /admin/api/performance +// --------------------------------------------------------------------- + +// adminPerfMaxIDs bounds GET /admin/api/performance's repeated "id" query +// parameter (plan §1.3(vi): "<=50"). +const adminPerfMaxIDs = 50 + +// adminPerfRow is one entity's (provider's or model's) availability and +// latency summary in GET /admin/api/performance, over the requested span. +// The five *Ms percentile fields and Count are all latency-derived and +// stay at their zero value (nil pointers, Count 0) when latency stats are +// disabled (LatencyEnabled false) or this entity has no latency +// observations at all in the span; Attempts/Failures[/Timeouts/ +// Failovers] are always populated regardless. +type adminPerfRow struct { + P50Ms *float64 `json:"p50Ms,omitempty"` + P95Ms *float64 `json:"p95Ms,omitempty"` + P99Ms *float64 `json:"p99Ms,omitempty"` + TTFBP50Ms *float64 `json:"ttfbP50Ms,omitempty"` + TTFBP95Ms *float64 `json:"ttfbP95Ms,omitempty"` + ID string `json:"id"` + Count int64 `json:"count"` + Attempts int64 `json:"attempts"` + Failures int64 `json:"failures"` + // Timeouts and Failovers are populated only for kind=provider — a + // per-(provider,model) scope tracks neither (plan §1.2's family + // table: prov/{name} only) — so they stay 0 (omitted) for kind=model. + Timeouts int64 `json:"timeouts,omitempty"` + Failovers int64 `json:"failovers,omitempty"` + Overflow bool `json:"overflow,omitempty"` +} + +// adminPerfResponse is the full body of GET /admin/api/performance. +// Rows is populated for the default (series=0) aggregate mode — one row +// per entity, summed over the whole span. Buckets/Points are populated +// only for series=1 (exactly one id) instead: one adminPerfRow per +// bucket, ID set to that bucket's own label. Rows is ALWAYS present and +// NEVER nil (an empty array `[]`, never `null`, in series=1 mode or when +// there are zero rows to report) — every other admin stats endpoint's own +// array-typed field (adminSeriesResponse.Unknown, adminUsageModelsResponse +// .Models, ...) already carries this same "never nil" contract, and a +// client that always does `for (const r of resp.rows)` without a null +// check must never see this one field be the exception. +type adminPerfResponse struct { + Kind string `json:"kind"` + Window string `json:"window"` + Rows []adminPerfRow `json:"rows"` + Buckets []string `json:"buckets,omitempty"` + Points []adminPerfRow `json:"points,omitempty"` + Span int `json:"span"` + Offset int `json:"offset"` + LatencyEnabled bool `json:"latencyEnabled"` +} + +// performanceValidWindow reports whether window is one of GET +// /admin/api/performance's two accepted values (plan §1.3(vi): "window +// hour|day" — no month, unlike most other stats endpoints: a monthly +// latency percentile would smear together far too much variance to be +// actionable). +func performanceValidWindow(window string) bool { + return window == windowHour || window == windowDay +} + +// latencyMetricNames returns every latencyDurationMetric/ +// latencyTTFBMetric name (limits.go) for the full bucket range: indices +// 0..len(latencyBucketBounds)-1 for each real bound, plus +// len(latencyBucketBounds) itself for the overflow bucket — matching +// latencyBucketIndex's own return range (metrics.go, WP-A). +func latencyMetricNames() []string { + n := len(latencyBucketBounds) + 1 + names := make([]string, 0, 2*n) + for i := 0; i < n; i++ { + names = append(names, latencyDurationMetric(i)) + } + for i := 0; i < n; i++ { + names = append(names, latencyTTFBMetric(i)) + } + return names +} + +// histogramQuantile computes the linearly-interpolated q-quantile (0..1) +// of a dense latency histogram: bounds are latencyBucketBounds' own +// ascending bucket upper bounds, in SECONDS; counts has len(bounds)+1 +// entries — counts[i] is bounds[i]'s own dense occurrence count for +// i < len(bounds), and counts[len(bounds)] is the overflow bucket (every +// observation past the largest bound). Returns ms (the quantile value in +// MILLISECONDS) and overflow=true when the computed rank falls in the +// overflow bucket, in which case ms is the largest bound in ms — the best +// lower bound available, since an overflow observation's true value is +// unknown past that point (plan §1.3(vi): "overflow bucket -> 600000 + +// overflow:true"). (0, false) when counts holds no observations at all. +func histogramQuantile(bounds []float64, counts []int64, q float64) (ms float64, overflow bool) { + var total int64 + for _, c := range counts { + total += c + } + if total == 0 { + return 0, false + } + target := q * float64(total) + var cum float64 + lower := 0.0 + for i, bound := range bounds { + c := float64(counts[i]) + if cum+c >= target { + if c <= 0 { + return bound * 1000, false + } + frac := (target - cum) / c + return (lower + frac*(bound-lower)) * 1000, false + } + cum += c + lower = bound + } + return bounds[len(bounds)-1] * 1000, true +} + +// perfScopeKinds resolves the counterStore scope kind for a performance +// entity's core availability counters (attempt/fail[/timeout/fover]) and +// its latency buckets, given the endpoint's own "kind" query value +// ("provider"|"model"): a provider entity reads both families under +// kindProvider (id = provider name); a model entity reads its core +// counters under kindProviderModel (attempt/fail only — plan §1.2's +// family table has no per-model timeout/fover) and its latency buckets +// under kindModel (id = canonical "provider/model" either way). +func perfScopeKinds(kind string) (coreKind, latencyKind string) { + if kind == "model" { + return kindProviderModel, kindModel + } + return kindProvider, kindProvider +} + +// buildPerfRow assembles one adminPerfRow from its already-read core +// counters (core, never nil) and latency bucket counts (lat, nil when +// latency stats are disabled or unread). +func buildPerfRow(id, coreKind string, core, lat map[string]int64, latencyEnabled bool) adminPerfRow { + row := adminPerfRow{ + ID: id, + Attempts: core[metricProvAttempt], + Failures: core[metricProvFail], + } + if coreKind == kindProvider { + row.Timeouts = core[metricProvTimeout] + row.Failovers = core[metricFover] + } + if !latencyEnabled || lat == nil { + return row + } + + n := len(latencyBucketBounds) + 1 + durationCounts := make([]int64, n) + ttfbCounts := make([]int64, n) + var durTotal, ttfbTotal int64 + for i := 0; i < n; i++ { + durationCounts[i] = lat[latencyDurationMetric(i)] + ttfbCounts[i] = lat[latencyTTFBMetric(i)] + durTotal += durationCounts[i] + ttfbTotal += ttfbCounts[i] + } + row.Count = durTotal + + var overflow bool + if durTotal > 0 { + p50, of := histogramQuantile(latencyBucketBounds, durationCounts, 0.5) + row.P50Ms = &p50 + overflow = overflow || of + p95, of := histogramQuantile(latencyBucketBounds, durationCounts, 0.95) + row.P95Ms = &p95 + overflow = overflow || of + p99, of := histogramQuantile(latencyBucketBounds, durationCounts, 0.99) + row.P99Ms = &p99 + overflow = overflow || of + } + if ttfbTotal > 0 { + t50, of := histogramQuantile(latencyBucketBounds, ttfbCounts, 0.5) + row.TTFBP50Ms = &t50 + overflow = overflow || of + t95, of := histogramQuantile(latencyBucketBounds, ttfbCounts, 0.95) + row.TTFBP95Ms = &t95 + overflow = overflow || of + } + row.Overflow = overflow + return row +} + +// serveAdminPerformance writes GET /admin/api/performance: provider or +// model availability + (opt-in) latency percentiles, aggregated over the +// requested span (series=0, the default) or bucket-by-bucket for exactly +// one id (series=1). +func (g *Gateway) serveAdminPerformance(sw *statusTrackingWriter, r *http.Request) { + q := r.URL.Query() + + kind := q.Get("kind") + if kind != "provider" && kind != "model" { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown kind") + return + } + window := q.Get("window") + if !performanceValidWindow(window) { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window") + return + } + span, ok := parseUsageModelsSpan(q.Get("span"), window) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span") + return + } + offset, ok := parseStatsOffset(q.Get("offset"), window, span) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset") + return + } + rawIDs := q["id"] + if len(rawIDs) > adminPerfMaxIDs { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "too many ids") + return + } + seriesRaw := q.Get("series") + var series bool + switch seriesRaw { + case "", "0": + series = false + case "1": + series = true + default: + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid series") + return + } + if series && len(rawIDs) != 1 { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "series requires exactly one id") + return + } + + now := g.limiter.now() + coreKind, latencyKind := perfScopeKinds(kind) + latencyEnabled := g.buildAdminFeatures().LatencyStats + + var ids []string + if len(rawIDs) > 0 { + for _, id := range rawIDs { + if kind == "provider" && g.seriesScopeExists(kindProvider, id) { + ids = append(ids, id) + } else if kind == "model" && g.scopeExists(kindModel, id) { + ids = append(ids, id) + } + } + } else if kind == "provider" { + for _, s := range g.registry.snapshot() { + ids = append(ids, s.name) + } + } else { + // Default: top 50 by request count in span (plan §1.3(vi)). + catalog := g.catalogModelScopeIDs() + // P7 fix (admin dashboard redesign verify round): this cap used to + // check len(catalog) alone, ignoring span entirely — the actual + // read below is len(catalog)*span keys (one metricReq bucket per + // model per span step), so a wide catalog combined with a large + // span could read far more than adminMaxKeysPerRequest ever meant + // to allow through before this 400 ever triggered. + if len(catalog)*span > adminMaxKeysPerRequest { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter") + return + } + totals, storeOK := g.chunkedModelSpanTotals(catalog, metricReq, window, now, span, offset) + if !storeOK { + writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable") + return + } + type ranked struct { + id string + req int64 + } + var nonzero []ranked + for i, id := range catalog { + if totals[i] > 0 { + nonzero = append(nonzero, ranked{id: id, req: totals[i]}) + } + } + sort.Slice(nonzero, func(i, j int) bool { + if nonzero[i].req != nonzero[j].req { + return nonzero[i].req > nonzero[j].req + } + return nonzero[i].id < nonzero[j].id + }) + if len(nonzero) > 50 { + nonzero = nonzero[:50] + } + for _, rk := range nonzero { + ids = append(ids, rk.id) + } + } + + // P1 fix (admin dashboard redesign verify round): the earlier + // `len(rawIDs) != 1` check only bounds how many id= parameters the + // CALLER sent — it says nothing about how many of them actually + // resolved to a real provider/model above. An unknown or renamed id + // (`series=1&id=nope`) resolves ids to an EMPTY slice, and ids[0] + // below panicked (recovered as a 500 "internal error" by errors.go's + // recoverPanic, but still a logged panic on every dashboard click + // against a stale id) instead of the 400 this endpoint's own README + // section already promised. + if series && len(ids) != 1 { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "series requires exactly one id") + return + } + + if series { + buckets, points, storeOK := g.perfSeries(coreKind, latencyKind, ids[0], window, now, span, offset, latencyEnabled) + if !storeOK { + writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable") + return + } + setAdminJSONHeaders(sw) + _ = json.NewEncoder(sw).Encode(adminPerfResponse{ + Kind: kind, Window: window, Span: span, Offset: offset, + // Rows stays non-nil even though series=1 never populates it — + // see adminPerfResponse's own "never nil" doc comment. + Rows: []adminPerfRow{}, Buckets: buckets, Points: points, LatencyEnabled: latencyEnabled, + }) + return + } + + coreMetrics := []string{metricProvAttempt, metricProvFail} + if coreKind == kindProvider { + coreMetrics = append(coreMetrics, metricProvTimeout, metricFover) + } + estimateKeys := len(ids) * len(coreMetrics) * span + if latencyEnabled { + estimateKeys += len(ids) * len(latencyMetricNames()) * span + } + if estimateKeys > adminMaxKeysPerRequest { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter") + return + } + + coreVals, storeOK := g.readSpanTotals(coreKind, ids, coreMetrics, window, now, span, offset) + if !storeOK { + writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable") + return + } + var latVals map[string]map[string]int64 + if latencyEnabled { + latVals, storeOK = g.readSpanTotals(latencyKind, ids, latencyMetricNames(), window, now, span, offset) + if !storeOK { + writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage store unavailable") + return + } + } + + rows := make([]adminPerfRow, len(ids)) + for i, id := range ids { + rows[i] = buildPerfRow(id, coreKind, coreVals[id], latVals[id], latencyEnabled) + } + + setAdminJSONHeaders(sw) + _ = json.NewEncoder(sw).Encode(adminPerfResponse{ + Kind: kind, Window: window, Span: span, Offset: offset, + Rows: rows, LatencyEnabled: latencyEnabled, + }) +} + +// perfSeries reads bucket-by-bucket (never summed) core+latency counters +// for exactly one entity — GET /admin/api/performance's series=1 mode — +// returning span bucket labels and one adminPerfRow per bucket, ID set to +// that bucket's own label. +func (g *Gateway) perfSeries(coreKind, latencyKind, id, window string, now time.Time, span, offset int, latencyEnabled bool) (buckets []string, points []adminPerfRow, ok bool) { + coreMetrics := []string{metricProvAttempt, metricProvFail} + if coreKind == kindProvider { + coreMetrics = append(coreMetrics, metricProvTimeout, metricFover) + } + var latMetrics []string + if latencyEnabled { + latMetrics = latencyMetricNames() + } + + // P6 fix (admin dashboard redesign verify round): every metric used to + // read via its own separate storeGetMulti round trip — a provider's + // series=1 with latency on cost 4 core + 28 latency metrics = 32 + // sequential round trips. Every metric's keys are built up front into + // ONE flat list instead, read through chunkedGetMulti's own bounded + // pipelining (limits.go), and sliced back apart per metric below — + // buckets come from the first metric's own key build since every + // metric shares identical bucket labels for the same kind/id/window/ + // span/offset. + allKeys := make([]string, 0, (len(coreMetrics)+len(latMetrics))*span) + for _, m := range coreMetrics { + keys, bkts := historyBucketKeysAt(coreKind, id, m, window, now, span, offset) + if buckets == nil { + buckets = bkts + } + allKeys = append(allKeys, keys...) + } + for _, m := range latMetrics { + keys, _ := historyBucketKeysAt(latencyKind, id, m, window, now, span, offset) + allKeys = append(allKeys, keys...) + } + + vals, storeOK := g.limiter.chunkedGetMulti(allKeys, usageModelsChunkKeys) + if !storeOK || len(vals) != len(allKeys) { + return nil, nil, false + } + + coreVals := make(map[string][]int64, len(coreMetrics)) + idx := 0 + for _, m := range coreMetrics { + coreVals[m] = vals[idx : idx+span] + idx += span + } + latVals := make(map[string][]int64, len(latMetrics)) + for _, m := range latMetrics { + latVals[m] = vals[idx : idx+span] + idx += span + } + + points = make([]adminPerfRow, span) + for b := 0; b < span; b++ { + core := make(map[string]int64, len(coreMetrics)) + for _, m := range coreMetrics { + core[m] = coreVals[m][b] + } + var lat map[string]int64 + if latencyEnabled { + lat = make(map[string]int64, len(latMetrics)) + for _, m := range latMetrics { + lat[m] = latVals[m][b] + } + } + row := buildPerfRow(buckets[b], coreKind, core, lat, latencyEnabled) + points[b] = row + } + return buckets, points, true +} diff --git a/stats_write_test.go b/stats_write_test.go new file mode 100644 index 0000000..358b138 --- /dev/null +++ b/stats_write_test.go @@ -0,0 +1,261 @@ +package traefikllmgateway + +import ( + "context" + "net/http" + "net/http/httptest" + "sync" + "testing" +) + +// This file measures counterStore round trips per request — the concrete +// evidence for admin-redesign WP-A's own hot-path promise (COMMON3.md, +// plan §2's command table): "round trips per request unchanged (2 sync + +// 1 async); cache hit +1 async RT; defaults (umodel/latency off) add <=3 +// INCRBY to account and <=2 SET/min to admission." Every test below drives +// a real Gateway (New(), the same constructor production uses) through a +// single-group chat request and counts calls to the limiter's own +// counterStore — never entries within a call, since a redisStore call +// pipelines its whole batch into ONE network round trip regardless of how +// many entries it carries (counterStore's own doc comment, limits.go), so +// counting CALLS is exactly counting round trips. + +// roundTripCountingStore wraps a real counterStore (a fresh memoryStore, +// so behavior is otherwise identical to a Redis-less deployment) and +// counts every call to its three methods. +type roundTripCountingStore struct { + delegate counterStore + mu sync.Mutex + calls int +} + +func (s *roundTripCountingStore) getMulti(keys []string) ([]int64, error) { + s.mu.Lock() + s.calls++ + s.mu.Unlock() + return s.delegate.getMulti(keys) +} + +func (s *roundTripCountingStore) incrMulti(entries []counterIncr) ([]int64, error) { + s.mu.Lock() + s.calls++ + s.mu.Unlock() + return s.delegate.incrMulti(entries) +} + +func (s *roundTripCountingStore) incrAndGetMulti(entries []counterIncr, reads []string) ([]int64, []int64, error) { + s.mu.Lock() + s.calls++ + s.mu.Unlock() + return s.delegate.incrAndGetMulti(entries, reads) +} + +func (s *roundTripCountingStore) total() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls +} + +// buildRoundTripTestGateway constructs a real Gateway for a single-group, +// single-user, single-provider chat deployment — cfgFn, when non-nil, may +// layer on admin/stats/cache/metrics config before construction — then +// swaps the limiter's store for a call-counting wrapper and forces spawn +// synchronous, so an async write (recordProviderAttempt, recordCacheHit) +// lands deterministically before the test inspects the count. The swap +// happens strictly AFTER New() returns: construction itself never calls a +// counterStore method, so it is never counted either way. +func buildRoundTripTestGateway(t *testing.T, srv *httptest.Server, cfgFn func(*Config)) (*Gateway, *roundTripCountingStore) { + t.Helper() + cfg := CreateConfig() + cfg.Providers = map[string]*ProviderConfig{ + "openai": {Type: "openai", BaseURL: srv.URL, APIKey: "k", Models: []string{"gpt-test"}}, + } + cfg.Groups = map[string]*GroupConfig{"default": {}} + cfg.Users = &UsersConfig{Inline: []*UserConfig{ + {Name: "alice", Group: "default", APIKey: "sk-alice", Limits: &LimitsConfig{}}, + }} + if cfgFn != nil { + cfgFn(cfg) + } + + next := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}) + h, err := New(context.Background(), next, cfg, "llmgw") + if err != nil { + t.Fatalf("New: %v", err) + } + gw, ok := h.(*Gateway) + if !ok { + t.Fatal("handler is not *Gateway") + } + + counting := &roundTripCountingStore{delegate: newMemoryStore()} + gw.limiter.store = counting + gw.limiter.spawn = func(f func()) { f() } // land every async write synchronously before this test inspects counts + return gw, counting +} + +const roundTripTestRespBody = `{"id":"chatcmpl-1","object":"chat.completion","model":"gpt-test","choices":[{"index":0,"message":{"role":"assistant","content":"hi"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}` + +func roundTripTestUpstream(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(roundTripTestRespBody)) + })) + t.Cleanup(srv.Close) + return srv +} + +func roundTripChatRequest(t *testing.T) *http.Request { + t.Helper() + body := map[string]any{"model": "gpt-test", "messages": []any{map[string]any{"role": "user", "content": "hi"}}} + return newUnifiedRequest(t, http.MethodPost, "/v1/chat/completions", "sk-alice", body) +} + +// TestRoundTrips_SingleGroupChat_Defaults_TwoSyncOneAsync is the +// command-table baseline: a single-group chat request with every admin +// stat off pays exactly 2 SYNCHRONOUS counterStore round trips +// (checkAndCount's storeIncrAndGetMulti, account's storeIncrMulti) plus 1 +// ASYNCHRONOUS one (recordProviderAttempt's spawned storeIncrMulti, +// forced synchronous here for a deterministic count) — 3 counterStore +// calls total. This is BEFORE, in the same sense the task's own +// instructions ask for: the number every "all stats on" comparison below +// must not exceed. +func TestRoundTrips_SingleGroupChat_Defaults_TwoSyncOneAsync(t *testing.T) { + srv := roundTripTestUpstream(t) + gw, counting := buildRoundTripTestGateway(t, srv, nil) + + rec := httptest.NewRecorder() + gw.ServeHTTP(rec, roundTripChatRequest(t)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + got := counting.total() + t.Logf("BEFORE (defaults, no admin stats): %d counterStore round trips for one single-group chat request", got) + if got != 3 { + t.Errorf("round trips = %d, want 3 (2 sync + 1 async, command table)", got) + } +} + +// TestRoundTrips_SingleGroupChat_AllStatsOn_NoNewSyncRoundTrips is this +// task's own deliverable: turning EVERY new admin stat on (userModel, +// latency; admin enabled; metrics enabled to also exercise the latency +// recorder's metrics-side wiring) for a single-group chat request that is +// neither a cache hit nor a failover adds ZERO new SYNCHRONOUS counterStore +// round trips — every new counter family this task adds to the request's +// own two synchronous calls (checkAndCount, accountWith) rides an EXISTING +// batch (accountWith's own umodel/latency extras blocks), never a new one. +// +// It DOES add exactly one new ASYNCHRONOUS round trip, recordLastSeen's own +// fire-and-forget write (P9 fix, admin dashboard redesign verify round): +// last-seen used to ride checkAndCount's own synchronous batch +// unconditionally, which meant a REJECTED request still moved that scope's +// last-seen timestamp forward. It is now recorded only from the "admission +// confirmed" branch, off the request's hot path via countAsync/l.spawn — +// exactly like recordProviderAttempt's own pre-existing async write — so +// this is a SEPARATE async call, not a batch this one rides. 2 sync + 2 +// async = 4 total, once per admitted request from a scope l.lastSeen.due +// has not already throttled within lastSeenGateThrottle (60s) — a fresh +// limiter's first request for a scope, as this test drives. +func TestRoundTrips_SingleGroupChat_AllStatsOn_NoNewSyncRoundTrips(t *testing.T) { + srv := roundTripTestUpstream(t) + gw, counting := buildRoundTripTestGateway(t, srv, func(cfg *Config) { + cfg.Admin = &AdminConfig{Enabled: true, Stats: &AdminStatsConfig{UserModel: true, Latency: true}} + cfg.Metrics = &MetricsConfig{Enabled: true} + }) + + rec := httptest.NewRecorder() + gw.ServeHTTP(rec, roundTripChatRequest(t)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + got := counting.total() + t.Logf("AFTER (admin.stats.userModel=true, admin.stats.latency=true, metrics on, no cache): %d counterStore round trips for the SAME single-group chat request", got) + if got != 4 { + t.Errorf("round trips with all stats on = %d, want 4 (2 sync + 2 async: checkAndCount, accountWith, recordProviderAttempt, recordLastSeen) — every new counter family rides an EXISTING sync batch except last-seen, which is its own async write by design (P9 fix)", got) + } +} + +// TestRoundTrips_SingleGroupChat_AdminEnabledStatsOff_FourRoundTripsWhenLastSeenDue +// is N4 (verify-redesign-final.md): README's own round-trip table used to +// fold "admin.enabled: false" and "admin.enabled: true with both +// admin.stats opt-ins off" into one "Defaults" row claiming 3 round trips +// for both. That is only true for the first: l.statsAdmin is +// adminEnabled(cfg) alone (llmgateway.go, newConfiguredLimiter) — +// independent of admin.stats.userModel/latency — so admin.enabled: true +// with BOTH opt-ins off still gates every "always-on-with-admin" family +// on, last-seen included. For a scope whose last-seen is DUE (this +// test's case: a fresh limiter, first request for the scope), that is +// recordLastSeen's own async write same as +// TestRoundTrips_SingleGroupChat_AllStatsOn_NoNewSyncRoundTrips — 4 round +// trips, not 3, even though neither opt-in stat is on and there is no +// umodel/latency entry in any batch. +func TestRoundTrips_SingleGroupChat_AdminEnabledStatsOff_FourRoundTripsWhenLastSeenDue(t *testing.T) { + srv := roundTripTestUpstream(t) + gw, counting := buildRoundTripTestGateway(t, srv, func(cfg *Config) { + cfg.Admin = &AdminConfig{Enabled: true} // Stats left nil: userModel/latency both default off + }) + + rec := httptest.NewRecorder() + gw.ServeHTTP(rec, roundTripChatRequest(t)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + + got := counting.total() + t.Logf("admin.enabled=true, stats.userModel/latency both off, last-seen due: %d counterStore round trips", got) + if got != 4 { + t.Errorf("round trips = %d, want 4 (2 sync + 2 async: checkAndCount, accountWith, recordProviderAttempt, recordLastSeen) — admin.enabled alone gates last-seen, not the opt-in stats", got) + } +} + +// TestRoundTrips_SingleGroupChat_CacheHit_AddsExactlyOneAsyncRoundTrip is +// the command table's one documented exception ("cache hit +1 async RT"): +// a response-cache HIT costs checkAndCount's own admission round trip +// (every metered route enforces request-rate limits before it even knows +// whether the response will be a hit) PLUS recordCacheHit's own async +// write — 2 counterStore calls, never account()'s or +// recordProviderAttempt's own (the hit branch returns before ever +// reaching either, since no upstream call was made). This is +1 over +// this feature's own pre-task baseline of "a hit costs only +// checkAndCount, 1 call" — cache stats did not exist before this task. +func TestRoundTrips_SingleGroupChat_CacheHit_AddsExactlyOneAsyncRoundTrip(t *testing.T) { + srv := roundTripTestUpstream(t) + redisLn := newBehavioralRedisServer(t) + gw, counting := buildRoundTripTestGateway(t, srv, func(cfg *Config) { + cfg.Redis = &RedisConfig{Address: redisLn.Addr().String()} + cfg.Cache = CacheConfig{Enabled: true, TTL: "1m"} + cfg.Admin = &AdminConfig{Enabled: true} + }) + // buildRoundTripTestGateway already swapped gw.limiter.store for a + // counting wrapper over a fresh memoryStore — the response CACHE + // itself is a *responseCache (cache.go), not a counterStore: its own + // Redis calls (via cfg.Redis's real behavioral fake) are never + // counted here, matching the command table's own "counterStore round + // trips" scope exactly. + + req1 := roundTripChatRequest(t) + rec1 := httptest.NewRecorder() + gw.ServeHTTP(rec1, req1) + if rec1.Code != http.StatusOK || rec1.Header().Get("X-Llmgw-Cache") != "miss" { + t.Fatalf("first request: status=%d cache=%q, want 200/miss", rec1.Code, rec1.Header().Get("X-Llmgw-Cache")) + } + baseline := counting.total() + t.Logf("miss (cache+admin on): %d cumulative counterStore round trips", baseline) + + req2 := roundTripChatRequest(t) + rec2 := httptest.NewRecorder() + gw.ServeHTTP(rec2, req2) + if rec2.Code != http.StatusOK || rec2.Header().Get("X-Llmgw-Cache") != "hit" { + t.Fatalf("second request: status=%d cache=%q, want 200/hit", rec2.Code, rec2.Header().Get("X-Llmgw-Cache")) + } + + hitOwnCalls := counting.total() - baseline + t.Logf("hit (cache+admin on): %d counterStore round trips for this ONE request (checkAndCount + recordCacheHit)", hitOwnCalls) + if hitOwnCalls != 2 { + t.Errorf("cache-hit round trips = %d, want 2 (checkAndCount + recordCacheHit; no account(), no recordProviderAttempt)", hitOwnCalls) + } +}