mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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"}},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+175
-76
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -467,6 +467,9 @@ func (g *Gateway) handleMCPFederated(w http.ResponseWriter, r *http.Request, u *
|
||||
writeLimitViolation(w, violation)
|
||||
return
|
||||
}
|
||||
// P9 fix (admin dashboard redesign verify round): last-seen only for
|
||||
// an admitted request — recordLastSeen's own doc comment (limits.go).
|
||||
g.limiter.recordLastSeen(scopes, g.limiter.now())
|
||||
|
||||
switch {
|
||||
case req.Method == "initialize":
|
||||
@@ -474,9 +477,9 @@ func (g *Gateway) handleMCPFederated(w http.ResponseWriter, r *http.Request, u *
|
||||
case req.Method == "ping":
|
||||
writeJSONRPCResult(w, format, req.ID, map[string]any{})
|
||||
case req.Method == "tools/list":
|
||||
g.mcpFederatedToolsList(w, format, r, req, allowedMCPServerNames(g.cfg, grp))
|
||||
g.mcpFederatedToolsList(w, format, r, req, allowedMCPServerNames(g.cfg, grp), u.name)
|
||||
case req.Method == "tools/call":
|
||||
g.mcpFederatedToolsCall(w, format, r, req, grp)
|
||||
g.mcpFederatedToolsCall(w, format, r, req, grp, u.name)
|
||||
case strings.HasPrefix(req.Method, "notifications/"):
|
||||
// A JSON-RPC notification carries no id and gets no response body
|
||||
// by definition; MCP's Streamable HTTP transport answers a
|
||||
@@ -1188,7 +1191,7 @@ const mcpFederatedFanoutConcurrency = 8
|
||||
// reach, because every call for that name is routed elsewhere). Logs
|
||||
// exactly which server's tool was dropped and which server tools/call
|
||||
// actually routes this name to.
|
||||
func (g *Gateway) mcpFederatedToolsList(w http.ResponseWriter, format mcpResponseFormat, r *http.Request, req jsonrpcRequest, names []string) {
|
||||
func (g *Gateway) mcpFederatedToolsList(w http.ResponseWriter, format mcpResponseFormat, r *http.Request, req jsonrpcRequest, names []string, caller string) {
|
||||
var (
|
||||
mu sync.Mutex
|
||||
wg sync.WaitGroup
|
||||
@@ -1416,7 +1419,11 @@ func (g *Gateway) mcpFederatedToolsList(w http.ResponseWriter, format mcpRespons
|
||||
for i, name := range dialed {
|
||||
attempted[i] = limitScope{kind: targetKindMCP, id: name}
|
||||
}
|
||||
g.limiter.countTargetRequests(attempted)
|
||||
// Target x caller (always-on-with-admin, DECISIONS): caller is
|
||||
// handleMCPFederated's own u.name, threaded through so every
|
||||
// dialed server's own tcaller counter attributes to the caller
|
||||
// whose credential authorized this fan-out.
|
||||
g.limiter.countTargetRequestsBy(caller, attempted)
|
||||
}
|
||||
|
||||
if len(failed) > 0 {
|
||||
@@ -1548,7 +1555,7 @@ func resolveFederatedTool(fullName string, allowedNames []string) (serverName, t
|
||||
// single resolved server, once the call was actually attempted against
|
||||
// it — the same "attempted, not necessarily succeeded" accounting
|
||||
// mcpFederatedToolsList's own doc comment explains for its own fan-out.
|
||||
func (g *Gateway) mcpFederatedToolsCall(w http.ResponseWriter, format mcpResponseFormat, r *http.Request, req jsonrpcRequest, grp *group) {
|
||||
func (g *Gateway) mcpFederatedToolsCall(w http.ResponseWriter, format mcpResponseFormat, r *http.Request, req jsonrpcRequest, grp *group, caller string) {
|
||||
var params mcpToolCallParams
|
||||
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
|
||||
writeJSONRPCErrorResponse(w, format, req.ID, jsonrpcInvalidParams, "invalid params")
|
||||
@@ -1588,7 +1595,9 @@ func (g *Gateway) mcpFederatedToolsCall(w http.ResponseWriter, format mcpRespons
|
||||
if err == nil || !errors.Is(err, context.Canceled) {
|
||||
g.targetHealth.record(targetKindMCP, serverName, targetURL, err == nil, err, time.Since(probeStart), targetHealthSourceTraffic)
|
||||
}
|
||||
g.limiter.countTargetRequest(targetKindMCP, serverName)
|
||||
// Target x caller (always-on-with-admin, DECISIONS): caller is
|
||||
// handleMCPFederated's own u.name, threaded through.
|
||||
g.limiter.countTargetRequestBy(caller, targetKindMCP, serverName)
|
||||
if err != nil {
|
||||
// P4 (review round 4): err here is commonly a *url.Error wrapping
|
||||
// targetURL verbatim, including its query string (this gateway's
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user