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

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

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

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

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

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

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
2026-09-23 16:40:45 +01:00
parent 8ac20d8d5e
commit 65b2f121c4
40 changed files with 7723 additions and 506 deletions
+358 -84
View File
@@ -69,11 +69,17 @@ func adminEnabled(cfg *Config) bool {
// routes: the page, any hashed asset under adminAssetsPathPrefix, or one
// of the /admin/api/* JSON routes. adminEventsPath (F3, v0.3 dashboard
// task) is defined in events.go, alongside the rest of that feature's own
// constants.
// constants; adminUsageSeriesPath/adminUsageTotalsPath/
// adminPerformancePath (stats_read.go), adminCatalogPath/
// adminConsumersPath (admin_catalog.go), and adminConfigPath
// (admin_config.go) — admin dashboard redesign, WP-B — follow the same
// convention.
func isAdminPath(path string) bool {
if path == adminPagePath || path == adminOverviewPath || path == adminUsagePath ||
path == adminUsageHistoryPath || path == adminUsageModelsPath || path == adminTargetsPath ||
path == adminEventsPath {
switch path {
case adminPagePath, adminOverviewPath, adminUsagePath, adminUsageHistoryPath, adminUsageModelsPath,
adminUsageSeriesPath, adminUsageTotalsPath, adminPerformancePath,
adminCatalogPath, adminConsumersPath, adminConfigPath,
adminTargetsPath, adminEventsPath:
return true
}
return strings.HasPrefix(path, adminAssetsPathPrefix)
@@ -148,6 +154,18 @@ func (g *Gateway) handleAdminAPI(sw *statusTrackingWriter, r *http.Request) {
g.serveAdminUsageHistory(sw, r)
case adminUsageModelsPath:
g.serveAdminUsageModels(sw, r)
case adminUsageSeriesPath:
g.serveAdminUsageSeries(sw, r)
case adminUsageTotalsPath:
g.serveAdminUsageTotals(sw, r)
case adminPerformancePath:
g.serveAdminPerformance(sw, r)
case adminCatalogPath:
g.serveAdminCatalog(sw)
case adminConsumersPath:
g.serveAdminConsumers(sw)
case adminConfigPath:
g.serveAdminConfig(sw)
case adminTargetsPath:
g.serveAdminTargets(sw)
case adminEventsPath:
@@ -223,13 +241,22 @@ func setAdminJSONHeaders(w http.ResponseWriter) {
// net/url's own URL.Redacted() does: Redacted() would still leave a bare
// embedded username in place, and a bare username with no separate
// password is itself commonly how a token gets embedded in a URL. A raw
// value that fails to parse as a URL is returned unchanged — it names no
// scheme/host/userinfo net/url can identify, so there is nothing
// structured left to strip.
// value that fails to parse as a URL returns unparseableBaseURLPlaceholder
// instead (P5 fix, admin dashboard redesign verify round: returning raw
// unchanged here defeated this whole function's purpose the moment a
// misconfigured baseUrl failed to parse, and url.Parse fails far more
// permissively than "not a URL at all" suggests — a password containing
// an unescaped '/' reads as an invalid port on the host, and a malformed
// '%' escape anywhere in the string is also a parse error; either way
// whatever credential was embedded survived verbatim in BOTH GET
// /admin/api/config's redaction, admin_config.go, and GET /admin/api/
// overview's provider view). A value that fails to parse has nothing
// structured left to strip selectively, so the only safe answer is to
// withhold it entirely.
func sanitizeBaseURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return raw
return unparseableBaseURLPlaceholder
}
u.User = nil
u.RawQuery = ""
@@ -237,6 +264,11 @@ func sanitizeBaseURL(raw string) string {
return u.String()
}
// unparseableBaseURLPlaceholder is what sanitizeBaseURL (above) returns in
// place of a raw value that failed url.Parse — named so every call site
// and test refers to the identical literal instead of restating it.
const unparseableBaseURLPlaceholder = "[unparseable url]"
// sanitizeProviderErr applies sanitizeBaseURL's userinfo/query-stripping
// treatment to lastErr before it is ever echoed by GET /admin/api/overview
// (v0.2 final review wave, 2026-08-20): a provider's discovery/refresh
@@ -626,11 +658,108 @@ type adminOverviewResponse struct {
Redis adminRedisView `json:"redis"`
Cache adminCacheView `json:"cache"`
Retry adminRetryView `json:"retry"`
// Features (admin dashboard redesign, WP-B) reports which of the
// new opt-in/always-on-with-admin statistics families this Gateway
// currently exposes — see adminFeaturesView's own doc comment.
Features adminFeaturesView `json:"features"`
// Pricing summarizes the live catalog's billing-price-source mix
// (adminPricingSummaryView's own doc comment) — a one-glance "how
// much of my fleet is actually priced" figure for the Home page,
// without a second round trip to GET /admin/api/catalog.
Pricing adminPricingSummaryView `json:"pricing"`
// WarningsDropped counts construction warnings that did not fit within
// configWarningsCap (logger.go) — omitted (0) means nothing was dropped.
WarningsDropped int `json:"warningsDropped,omitempty"`
}
// adminFeaturesView reports which of the admin dashboard redesign's new
// statistics families this Gateway currently exposes — GET
// /admin/api/overview, GET /admin/api/config, and every stats_read.go
// endpoint's own 404 gate all echo the SAME struct, built by the SAME
// buildAdminFeatures (below), so the webui can gate a whole page section
// on one flag without guessing at a second endpoint's own availability.
//
// UserModelStats and LatencyStats are opt-in (DECISIONS Q4: both default
// OFF), read from Config.Admin.Stats. CacheStats, LastSeen and Failover
// are "always-on-with-admin" (DECISIONS: zero/near-zero cost, no new
// round trips on the request path) — CacheStats additionally requires
// the response cache itself to be configured (nothing to report
// otherwise); LastSeen and Failover need only adminEnabled, and are
// reported true whenever this endpoint answers at all, kept as their own
// explicit flags (rather than assumed true) so a future version gate
// never has to change this struct's shape to turn one off.
type adminFeaturesView struct {
UserModelStats bool `json:"userModelStats"`
LatencyStats bool `json:"latencyStats"`
CacheStats bool `json:"cacheStats"`
LastSeen bool `json:"lastSeen"`
Failover bool `json:"failover"`
}
// buildAdminFeatures reads Config.Admin.Stats (nil-safe: an admin block
// with no stats sub-block, or predating this feature, means both opt-in
// flags stay off — DECISIONS Q4's own defaults) and g.cache's presence to
// assemble adminFeaturesView. Shared by GET /admin/api/overview
// (buildAdminOverview, below), GET /admin/api/config (admin_config.go),
// and every stats_read.go endpoint's own feature-gated 404 check, so
// they can never disagree about which families are live.
func (g *Gateway) buildAdminFeatures() adminFeaturesView {
var userModel, latency bool
if g.cfg.Admin != nil && g.cfg.Admin.Stats != nil {
userModel = g.cfg.Admin.Stats.UserModel
latency = g.cfg.Admin.Stats.Latency
}
return adminFeaturesView{
UserModelStats: userModel,
LatencyStats: latency,
CacheStats: g.cache != nil,
LastSeen: true,
Failover: true,
}
}
// adminPricingSummaryView counts the live model catalog's rows by
// billing price source (billingPriceSource, pricing.go) — Override,
// Builtin, Litellm, Free and Unpriced sum to the catalog's total model
// count. GET /admin/api/overview's own Pricing field (buildAdminOverview,
// below); GET /admin/api/catalog's per-model PriceSource field
// (admin_catalog.go) is this same classification, unaggregated.
type adminPricingSummaryView struct {
Override int `json:"override"`
Builtin int `json:"builtin"`
Litellm int `json:"litellm"`
Free int `json:"free"`
Unpriced int `json:"unpriced"`
}
// buildAdminPricingSummary counts every catalogued (provider, model)
// pair in snaps by billingPriceSource's own result — the SAME resolution
// GET /admin/api/catalog applies per model (admin_catalog.go), summed
// here rather than read from a second registry snapshot, so the two
// views can never disagree about the fleet-wide mix even though they run
// against independently-taken snapshots.
func buildAdminPricingSummary(snaps []providerSnapshot, overrides map[string]*ModelPricing, meta map[string]*ModelMetaConfig) adminPricingSummaryView {
var summary adminPricingSummaryView
for _, s := range snaps {
for _, model := range s.models {
canonical := providerModelScopeID(s.name, model)
switch source, _ := billingPriceSource(canonical, model, overrides, meta); source {
case priceSourceOverride:
summary.Override++
case priceSourceBuiltin:
summary.Builtin++
case priceSourceLitellm:
summary.Litellm++
case priceSourceFree:
summary.Free++
default:
summary.Unpriced++
}
}
}
return summary
}
// buildAdminOverview assembles adminOverviewResponse from the registry,
// limiter, cache, and authStore read hooks — nothing here mutates any of
// them.
@@ -732,15 +861,7 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
groups[i] = adminGroupView{Name: gs.name, Limits: gs.limits, MemberCount: gs.memberCount}
}
aliasSnaps := g.registry.aliasSnapshot()
aliases := make([]adminAliasView, len(aliasSnaps))
for i, a := range aliasSnaps {
aliases[i] = adminAliasView{
Alias: a.Alias,
Target: a.Target,
ModelMeta: buildAdminModelMetaView(g.registry.resolveMetaForAliasName(a.Alias)),
}
}
aliases := g.buildAdminAliasViews()
warnings, warningsDropped := g.configWarningsSnapshot()
return adminOverviewResponse{
@@ -750,6 +871,8 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
Retry: retryView,
Groups: groups,
Aliases: aliases,
Features: g.buildAdminFeatures(),
Pricing: buildAdminPricingSummary(snaps, g.cfg.Pricing, g.cfg.ModelMeta),
Version: pluginVersion,
Replica: g.replica,
Instance: g.name,
@@ -758,6 +881,24 @@ func (g *Gateway) buildAdminOverview() adminOverviewResponse {
}
}
// buildAdminAliasViews returns every configured model alias's read-only
// view (adminAliasView) — shared by GET /admin/api/overview's own
// Aliases field (buildAdminOverview, above) and GET /admin/api/catalog's
// identical field (admin_catalog.go), so the two can never list a
// different alias set or disagree about one alias's resolved metadata.
func (g *Gateway) buildAdminAliasViews() []adminAliasView {
aliasSnaps := g.registry.aliasSnapshot()
aliases := make([]adminAliasView, len(aliasSnaps))
for i, a := range aliasSnaps {
aliases[i] = adminAliasView{
Alias: a.Alias,
Target: a.Target,
ModelMeta: buildAdminModelMetaView(g.registry.resolveMetaForAliasName(a.Alias)),
}
}
return aliases
}
// serveAdminOverview writes buildAdminOverview's result as JSON.
func (g *Gateway) serveAdminOverview(w http.ResponseWriter) {
setAdminJSONHeaders(w)
@@ -810,7 +951,13 @@ type adminUsageEntryView struct {
// attributed to this scope today (F4, v0.3 dashboard task) — read via
// windowKey's metric "rej", window "day" (limits.go's metricRej).
RejectionsPerDay int64 `json:"rejectionsPerDay"`
StoreDown bool `json:"storeDown,omitempty"`
// LastSeen is this scope's most recent admitted-request instant, unix
// seconds (admin dashboard redesign, WP-B; DECISIONS Q6) — 0
// (omitted) means never observed, or unknown (a storeDown read).
// Sourced from scopeUsage.lastSeen (limits.go, WP-A step 1), the
// usageKeysPerScope's 10th key.
LastSeen int64 `json:"lastSeen,omitempty"`
StoreDown bool `json:"storeDown,omitempty"`
}
// adminUsageResponse is the full body of GET /admin/api/usage: every
@@ -841,6 +988,7 @@ func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView {
CostPerDayMicroUSD: su.costPerDayMicros,
CostPerMonthMicroUSD: su.costPerMonthMicros,
RejectionsPerDay: su.rejectionsPerDay,
LastSeen: su.lastSeen,
StoreDown: su.storeDown,
}
}
@@ -884,7 +1032,14 @@ func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView {
// store error marks every scope in ONE call storeDown together; chunking
// narrows that blast radius to one chunk's worth of scopes instead of the
// whole response, which is a strict improvement, not a new risk.
const adminUsageChunkScopes = 200
//
// Lowered from 200 to 160 (admin dashboard redesign, WP-B) when
// usageKeysPerScope grew from 9 to 10 for the new lastSeen counter
// (limits.go, WP-A step 1): 160 scopes * 10 keys/scope = 1,600 keys per
// chunk, keeping this endpoint's per-round-trip key count at the same
// order of magnitude this constant has always targeted, rather than
// letting it grow every time a future per-scope counter is added here.
const adminUsageChunkScopes = 160
// chunkedCurrentUsage calls limiter.currentUsage in batches of at most
// adminUsageChunkScopes scopes at a time, concatenating the results in
@@ -1290,8 +1445,27 @@ type adminUsageModelEntryView struct {
TokensIn *int64 `json:"tokensIn,omitempty"`
TokensOut *int64 `json:"tokensOut,omitempty"`
CostMicroUSD *int64 `json:"costMicroUsd,omitempty"`
ID string `json:"id"`
Value int64 `json:"value"`
// CacheHits and CacheSavedMicroUsd (admin dashboard redesign, WP-B)
// are populated only in detail mode, only when features.cacheStats
// (the response cache is configured), AND only when window is "day"
// or "month" — nil for a window="hour" request too (P2 fix,
// serveAdminUsageModelsDetail's own doc comment): a model's chit/csave
// only ever have a DAY bucket, and there is no exact way to recover an
// hour-range value from whole-day sums.
CacheHits *int64 `json:"cacheHits,omitempty"`
CacheSavedMicroUSD *int64 `json:"cacheSavedMicroUsd,omitempty"`
// R402 (admin dashboard redesign, WP-B) is populated only in detail
// mode: how many requests for this model were refused as unpriced
// under a caller's cost budget (metricR402) over the requested span.
R402 *int64 `json:"r402,omitempty"`
ID string `json:"id"`
// PriceSource (admin dashboard redesign, WP-B) is populated only in
// detail mode: billingPriceSource's own result (pricing.go) for this
// model — the same "override"|"builtin"|"litellm"|"free"|"unpriced"
// vocabulary GET /admin/api/catalog's adminCatalogModel.PriceSource
// already uses.
PriceSource string `json:"priceSource,omitempty"`
Value int64 `json:"value"`
// Free is populated only by serveAdminUsageModelsDetail too; false
// (the non-detail zero value) is dropped by its own omitempty.
Free bool `json:"free,omitempty"`
@@ -1308,7 +1482,12 @@ type adminUsageModelsResponse struct {
Window string `json:"window"`
Models []adminUsageModelEntryView `json:"models"`
Span int `json:"span"`
Detail bool `json:"detail,omitempty"`
// Offset (admin dashboard redesign, WP-B) echoes the resolved offset
// (0 when the request carried no ?offset= parameter —
// parseStatsOffset's own default) — how many whole window-steps back
// from the current bucket this ranking's span starts.
Offset int `json:"offset"`
Detail bool `json:"detail,omitempty"`
}
// usageModelsChunkKeys bounds how many counterStore keys
@@ -1332,66 +1511,32 @@ const usageModelsChunkKeys = 1600
// mismatched read) fails the whole call: a ranking silently missing an
// arbitrary subset of models is worse than a 503, so this never returns a
// partial ranking.
func (g *Gateway) chunkedModelSpanTotals(ids []string, metric, window string, now time.Time, span int) ([]int64, bool) {
perChunk := usageModelsChunkKeys / span
if perChunk < 1 {
perChunk = 1
//
// P12 fix (admin dashboard redesign verify round): now a thin
// kind=kindModel wrapper over limits.go's spanTotalsMulti — the ONE
// production span-sum reader this function's own chunking logic used to
// duplicate (byte-for-byte) against admin.go's own separate
// chunkedModelSpanTotalsMulti below AND limits_helpers_test.go's
// test-only modelSpanTotals/modelSpanTotalsMulti. See spanTotalsMulti's
// own doc comment for the chunking contract this preserves unchanged.
func (g *Gateway) chunkedModelSpanTotals(ids []string, metric, window string, now time.Time, span, offset int) ([]int64, bool) {
out, ok := g.limiter.spanTotalsMulti(kindModel, ids, []string{metric}, window, now, span, offset)
if !ok {
return nil, false
}
out := make([]int64, 0, len(ids))
for len(ids) > 0 {
n := perChunk
if n > len(ids) {
n = len(ids)
}
totals, ok := g.limiter.modelSpanTotals(ids[:n], metric, window, now, span)
if !ok {
return nil, false
}
out = append(out, totals...)
ids = ids[n:]
}
return out, true
return out[0], true
}
// chunkedModelSpanTotalsMulti is chunkedModelSpanTotals generalized to
// several metrics read together (GET /admin/api/usage/models' detail=1
// mode, free-models feature: it needs all four metrics —
// req/tokin/tokout/cost — for every candidate id, not just the one the
// ranking sorts by). Each chunk's own key count is
// len(chunk)*span*len(metrics), so perChunk divides usageModelsChunkKeys
// by span*len(metrics) instead of span alone — chunkedModelSpanTotals'
// own reasoning, extended for the extra metrics dimension. now is
// resolved once by the caller and threaded through every chunk, exactly
// like chunkedModelSpanTotals, and any chunk that fails fails the whole
// call for the identical reason: a detail ranking silently missing an
// arbitrary subset of models is worse than a 503.
func (g *Gateway) chunkedModelSpanTotalsMulti(ids, metrics []string, window string, now time.Time, span int) ([][]int64, bool) {
if len(metrics) == 0 || span < 1 {
return nil, true
}
perChunk := usageModelsChunkKeys / (span * len(metrics))
if perChunk < 1 {
perChunk = 1
}
out := make([][]int64, len(metrics))
for m := range out {
out[m] = make([]int64, 0, len(ids))
}
for len(ids) > 0 {
n := perChunk
if n > len(ids) {
n = len(ids)
}
totals, ok := g.limiter.modelSpanTotalsMulti(ids[:n], metrics, window, now, span)
if !ok {
return nil, false
}
for m := range out {
out[m] = append(out[m], totals[m]...)
}
ids = ids[n:]
}
return out, true
// ranking sorts by). P12 fix (admin dashboard redesign verify round): now
// a thin kind=kindModel wrapper over limits.go's spanTotalsMulti — see
// chunkedModelSpanTotals' own doc comment, immediately above, for what
// this replaced.
func (g *Gateway) chunkedModelSpanTotalsMulti(ids, metrics []string, window string, now time.Time, span, offset int) ([][]int64, bool) {
return g.limiter.spanTotalsMulti(kindModel, ids, metrics, window, now, span, offset)
}
// serveAdminUsageModels writes GET /admin/api/usage/models' ranking of the
@@ -1460,6 +1605,11 @@ func (g *Gateway) serveAdminUsageModels(sw *statusTrackingWriter, r *http.Reques
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span")
return
}
offset, ok := parseStatsOffset(q.Get("offset"), window, span)
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid offset")
return
}
prefix, ok := parseUsageModelsPrefix(q.Get("prefix"))
if !ok {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid prefix")
@@ -1481,13 +1631,34 @@ func (g *Gateway) serveAdminUsageModels(sw *statusTrackingWriter, r *http.Reques
}
ids = filtered
}
// P7 fix (admin dashboard redesign verify round): this cap used to
// check len(ids)*span regardless of detail mode, but detail mode
// reads up to 4 base metrics (req/tokin/tokout/cost) PLUS r402
// (always, P2 fix) PLUS chit/csave (when features.cacheStats and the
// window isn't hour) per id — up to 7x the single-metric estimate
// this check assumed. keyMultiplier over-estimates when a month
// window's cache/r402 reads convert to a smaller, retention-bounded
// day span (P2 fix, monthRangeToDayBuckets) — that only ever rejects
// a request earlier than strictly necessary, never lets an oversized
// one through.
keyMultiplier := 1
if detail {
g.serveAdminUsageModelsDetail(sw, ids, metric, window, span, limit)
keyMultiplier = len(usageModelsDetailMetrics()) + 1 // +1: r402, always read
if g.buildAdminFeatures().CacheStats {
keyMultiplier += 2 // chit, csave
}
}
if len(ids)*span*keyMultiplier > adminMaxKeysPerRequest {
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "range too large; narrow span or filter")
return
}
totals, storeOK := g.chunkedModelSpanTotals(ids, metric, window, g.limiter.now(), span)
if detail {
g.serveAdminUsageModelsDetail(sw, ids, metric, window, span, offset, limit)
return
}
totals, storeOK := g.chunkedModelSpanTotals(ids, metric, window, g.limiter.now(), span, offset)
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable")
return
@@ -1511,7 +1682,7 @@ func (g *Gateway) serveAdminUsageModels(sw *statusTrackingWriter, r *http.Reques
}
setAdminJSONHeaders(sw)
_ = json.NewEncoder(sw).Encode(adminUsageModelsResponse{Metric: metric, Window: window, Span: span, Models: models})
_ = json.NewEncoder(sw).Encode(adminUsageModelsResponse{Metric: metric, Window: window, Span: span, Offset: offset, Models: models})
}
// usageModelsDetailMetrics is the fixed metric read/unpack order
@@ -1576,15 +1747,107 @@ func adminUsageModelFree(canonical, bare string, overrides map[string]*ModelPric
// once two free models tie on the requested metric (typically cost, both
// zero), requests is the natural next signal, ahead of the id fallback
// every ranking already uses to stay stable across polls.
func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []string, metric, window string, span, limit int) {
// monthRangeToDayBuckets converts a windowMonth span/offset (relative to
// now) into the windowDay span/offset covering the EXACT SAME calendar
// range — P2 fix, admin dashboard redesign verify round: GET
// /admin/api/usage/models' detail=1 mode needs this for the counter
// families that have no month bucket of their own (r402) or only ever
// have a DAY bucket at the model scope (chit/csave — plan §1.2's "model
// day only" row). Month and day buckets both align on calendar
// boundaries (historyStepBack's own month case always anchors on the 1st
// of a month, limits.go), so this conversion is exact, not an
// approximation: every day it names falls entirely inside the requested
// month range, and every day in that range is named. A day outside the
// day family's own retention (historyMaxSpan(windowDay), 35) simply reads
// back 0 through the store, which is the correct answer for data that
// was never kept that long — the returned span is capped at that
// retention purely to avoid building a key list for months of days none
// of which could possibly hold live data.
func monthRangeToDayBuckets(now time.Time, span, offset int) (daySpan, dayOffset int) {
u := now.UTC()
today := time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC)
monthAnchor := time.Date(u.Year(), u.Month(), 1, 0, 0, 0, 0, time.UTC)
lastMonth := monthAnchor.AddDate(0, -offset, 0)
firstMonth := lastMonth.AddDate(0, -(span - 1), 0)
lastDay := today
if offset > 0 {
lastDay = lastMonth.AddDate(0, 1, -1) // last calendar day of lastMonth
}
dayOffset = int(today.Sub(lastDay).Hours() / 24)
daySpan = int(lastDay.Sub(firstMonth).Hours()/24) + 1
if max := historyMaxSpan(windowDay) - dayOffset; daySpan > max {
if max < 1 {
max = 1
}
daySpan = max
}
return daySpan, dayOffset
}
func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []string, metric, window string, span, offset, limit int) {
now := g.limiter.now()
metrics := usageModelsDetailMetrics()
totalsByMetric, storeOK := g.chunkedModelSpanTotalsMulti(ids, metrics, window, g.limiter.now(), span)
totalsByMetric, storeOK := g.chunkedModelSpanTotalsMulti(ids, metrics, window, now, span, offset)
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable")
return
}
valueIdx := usageModelsMetricIndex(metric)
// r402 (admin dashboard redesign, WP-B; P2 fix, verify round): ALWAYS
// read, independent of features.cacheStats — r402 counts refused-
// unpriced-under-a-cost-budget requests, unrelated to the response
// cache. It used to be folded into the SAME cache-gated read as
// chit/csave, forced to windowDay regardless of the caller's own
// window — wrong on two counts: gated on cache config at all, and
// accountWith's own recordUnpriced402 (limits.go) writes a model's
// r402 at BOTH hour and day, so an hour-window request silently read
// 24-120x too many day buckets while a month-window request read only
// 1 day per month instead of the whole month. Read at the caller's
// own window when r402 has a bucket there (hour or day, exact);
// convert to the covering day buckets when it is month (r402 has no
// month bucket — monthRangeToDayBuckets, above, is exact for this).
r402Window, r402Span, r402Offset := window, span, offset
if window == windowMonth {
r402Window = windowDay
r402Span, r402Offset = monthRangeToDayBuckets(now, span, offset)
}
r402Multi, storeOK := g.chunkedModelSpanTotalsMulti(ids, []string{metricR402}, r402Window, now, r402Span, r402Offset)
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable")
return
}
r402ByID := r402Multi[0]
// chit/csave (admin dashboard redesign, WP-B): only when
// features.cacheStats (the response cache is configured) — nil
// otherwise, as before. Unlike r402, a model's chit/csave are ONLY
// ever written at windowDay (recordCacheHit, limits.go — plan §1.2's
// "model day only" row), so there is no hour bucket to read at all:
// window==day reads directly; window==month converts to the exact
// covering day buckets, same as r402 above; window==hour is left
// OMITTED (both fields stay nil, not a fabricated or over-counted
// number) — an hour range's true chit/csave cannot be recovered from
// whole-day sums without also counting every hour of those days OUTSIDE
// the requested range, so there is no exact answer to give (documented
// in README.md's GET /admin/api/usage/models section).
cacheEnabled := g.buildAdminFeatures().CacheStats
var chitByID, csaveByID []int64
if cacheEnabled && window != windowHour {
cacheWindow, cacheSpan, cacheOffset := window, span, offset
if window == windowMonth {
cacheWindow = windowDay
cacheSpan, cacheOffset = monthRangeToDayBuckets(now, span, offset)
}
var cacheMulti [][]int64
cacheMulti, storeOK = g.chunkedModelSpanTotalsMulti(ids, []string{metricChit, metricCsave}, cacheWindow, now, cacheSpan, cacheOffset)
if !storeOK {
writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable")
return
}
chitByID, csaveByID = cacheMulti[0], cacheMulti[1]
}
models := make([]adminUsageModelEntryView, 0, len(ids))
for i, id := range ids {
req := totalsByMetric[0][i]
@@ -1595,7 +1858,9 @@ func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []st
continue
}
_, bare, _ := strings.Cut(id, "/")
models = append(models, adminUsageModelEntryView{
source, _ := billingPriceSource(id, bare, g.cfg.Pricing, g.cfg.ModelMeta)
r402 := r402ByID[i]
entry := adminUsageModelEntryView{
ID: id,
Value: totalsByMetric[valueIdx][i],
Requests: &req,
@@ -1603,7 +1868,16 @@ func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []st
TokensOut: &tokOut,
CostMicroUSD: &cost,
Free: adminUsageModelFree(id, bare, g.cfg.Pricing, g.cfg.ModelMeta),
})
PriceSource: source,
R402: &r402,
}
if chitByID != nil {
chit := chitByID[i]
csave := csaveByID[i]
entry.CacheHits = &chit
entry.CacheSavedMicroUSD = &csave
}
models = append(models, entry)
}
sort.Slice(models, func(i, j int) bool {
if models[i].Value != models[j].Value {
@@ -1619,7 +1893,7 @@ func (g *Gateway) serveAdminUsageModelsDetail(sw *statusTrackingWriter, ids []st
}
setAdminJSONHeaders(sw)
_ = json.NewEncoder(sw).Encode(adminUsageModelsResponse{Metric: metric, Window: window, Span: span, Models: models, Detail: true})
_ = json.NewEncoder(sw).Encode(adminUsageModelsResponse{Metric: metric, Window: window, Span: span, Offset: offset, Models: models, Detail: true})
}
// parseEventsLimit parses GET /admin/api/events' "limit" query parameter
+304
View File
@@ -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())
}
+337
View File
@@ -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)
}
}
}
+169
View File
@@ -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())
}
+333
View File
@@ -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:[email protected]/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/[email protected]/v1"},
{"malformed percent escape", "https://user:50%[email protected]/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/[email protected]/v1"
cfg.Providers["zeta"].BaseURL = "https://user:50%[email protected]/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
}
}
+427
View File
@@ -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)
}
}
+247
View File
@@ -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")
}
}
+250 -2
View File
@@ -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
+290
View File
@@ -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")
}
}
+56 -37
View File
@@ -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 })
+112
View File
@@ -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"}},
+23
View File
@@ -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
+39
View File
@@ -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)
}
})
}
}
+7
View File
@@ -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
+857 -192
View File
File diff suppressed because it is too large Load Diff
+175 -76
View File
@@ -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)
}
+815
View File
@@ -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)
}
}
}
+80 -6
View File
@@ -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 \"<raw>\": <reason>"), 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
+63
View File
@@ -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")
}
}
+8 -1
View File
@@ -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 {
+45
View File
@@ -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)
}
}
}
+15 -6
View File
@@ -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, &params); 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
+71
View File
@@ -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)
}
}
}
+22 -5
View File
@@ -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
+62
View File
@@ -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)
}
}
+89 -10
View File
@@ -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)
+131
View File
@@ -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)
+67 -49
View File
@@ -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)})
+17
View File
@@ -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
}
+134
View File
@@ -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)
}
}
-6
View File
@@ -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
+12
View File
@@ -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.
+50 -17
View File
@@ -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),
+72
View File
@@ -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)
}
}
+38 -8
View File
@@ -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
+132
View File
@@ -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)
}
}
+87 -7
View File
@@ -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
}
+338
View File
@@ -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)
}
}
+1130
View File
File diff suppressed because it is too large Load Diff
+261
View File
@@ -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)
}
}