Files
traefik-llmgateway/limits.go
T
lukaszraczylo 65b2f121c4 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
2026-09-23 16:40:45 +01:00

3081 lines
149 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package traefikllmgateway
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// storeErrorLogEvery bounds how often the limiter logs a configured
// store's operation error: once per this interval, regardless of how many
// operations fail in between. A backend outage under load must not flood
// the log with one line per request.
const storeErrorLogEvery = 30 * time.Second
// storeDownLatchFor bounds how long the limiter avoids a configured store
// after any operation against it fails: for this long after the failure,
// every store operation short-circuits straight to the fail-open/
// fail-closed policy without attempting the network call at all. Each
// respClient call is already bounded to ~respCallTimeout on its own
// (resp.go), but one request can still touch several counters (its own
// and its group's, request/token/cost, day/month) — without this latch,
// every one of those pays a fresh bounded wait against a store that just
// told the previous op it was unreachable. After the window, the next
// operation probes the real store again: a natural half-open retry, no
// separate recovery state needed.
const storeDownLatchFor = 5 * time.Second
// Window identifiers used throughout windowKey, counter metrics, and
// retry-after calculation. windowHour is stats-only (v0.2 data-layer
// task, see hourWindowTTL): checkAndCount/account both write it, but no
// LimitsConfig field ever names it, so checkAndCount has nothing to
// evaluate it against — GET /admin/api/usage/history (admin.go) is its
// only reader.
const (
windowHour = "hour"
windowMin = "min"
windowDay = "day"
windowMonth = "month"
)
// Counter metric names embedded in windowKey. metricTokIn/metricTokOut
// replace a single combined "tok" metric (v0.2 data-layer task, folding
// the operator's tokens-in/tokens-out split directive): account records
// usage.prompt under metricTokIn and usage.completion under metricTokOut
// separately, so the admin usage/history APIs can chart each direction on
// its own. A TokensPerDay/TokensPerMonth limit still enforces a TOTAL
// budget across both — see checkAndCount's own probe loop and
// buildBudgetProbes' tokens field, below.
const (
metricReq = "req"
metricTokIn = "tokin"
metricTokOut = "tokout"
metricCost = "cost"
// metricRej is the fleet-wide rejection counter F4 (v0.3 dashboard
// task) adds: a day-bucketed count of every checkAndCount rejection,
// per scope, folded into the SAME storeIncrMulti round trip
// settleRejection already pays to compensate other scopes' counters
// (below) — never a second round trip. Read back as
// scopeUsage.rejectionsPerDay / adminUsageEntryView.RejectionsPerDay.
metricRej = "rej"
)
// New counter-family metric names (admin-redesign WP-A): landed together,
// early, so WP-B's read-side endpoints (admin.go, stats_read.go) can code
// against the agreed names while WP-A finishes wiring their writers.
// metricProvTimeout is the provider-level "did not answer within budget"
// counter (recordProviderAttempt, below) — distinct from metricProvFail
// (isTransient's broader classification): every timeout is a fail, but not
// every fail is a timeout. metricFover is the failover counter
// (accountWith's accountExtras.failoverFrom): +1 against the FROM
// provider every time a request moves to its next candidate. metricR402
// is the "refused: unpriced model under a cost budget" counter
// (recordUnpriced402). metricChit/metricCsave/metricCmiss are the response
// cache's hit/avoided-cost/miss counters (recordCacheHit, accountWith's
// accountExtras.cacheMiss) — csave is micro-USD, the same unit metricCost
// already uses, not a request count.
const (
metricProvTimeout = "timeout"
metricFover = "fover"
metricR402 = "r402"
metricChit = "chit"
metricCsave = "csave"
metricCmiss = "cmiss"
)
// kindUserModel and kindTargetCaller are two more limitScope.kind (and
// admin-API scope-kind) strings this task adds, alongside kindProvider/
// kindProviderModel/kindModel above: kindUserModel is the opt-in per-
// (user, model) breakdown (userModelScopeID, below; admin.stats.userModel);
// kindTargetCaller is the per-(target, caller) breakdown countTargetRequestsBy
// writes (targetCallerScopeID, below). Both embed kind as their counter
// key's own leading segment, the same collision-safety every other kind
// constant in this file already relies on.
const (
kindUserModel = "umodel"
kindTargetCaller = "tcaller"
)
// userModelScopeID builds kindUserModel's id for (user, canonical): the
// user name's own byte length (decimal ASCII), ":", then the user name
// immediately followed by canonical with NO separator between them. The
// length prefix is what makes this unambiguous to split back apart later
// (WP-B's usage/totals?kind=usermodel reader): a user name may itself
// contain any character a configured username allows, including one that
// could otherwise collide with a plain fixed separator, but canonical
// always starts exactly len(user) bytes after the first ":".
func userModelScopeID(user, canonical string) string {
return strconv.Itoa(len(user)) + ":" + user + canonical
}
// targetCallerScopeID builds kindTargetCaller's id for (targetKind,
// target, caller): "{mcp|agent}/{target}/{caller}" — targetKind is
// targetScopeKind's own output ("mcp" or "agent", mcp_a2a.go), so this
// reuses the identical vocabulary GET /admin/api/targets already exposes
// rather than minting a third one.
func targetCallerScopeID(targetKind, target, caller string) string {
return targetKind + "/" + target + "/" + caller
}
// latencyDurationMetric and latencyTTFBMetric name the per-bucket latency
// counters accountWith writes (opt-in, admin.stats.latency): idx is
// latencyBucketIndex's own return value (metrics.go) — 0..len(
// latencyBucketBounds)-1 for a real bucket, len(latencyBucketBounds) itself
// for the overflow bucket — so "ld00".."ld13"/"lt00".."lt13" for the 13
// configured bounds plus one overflow bucket each, matching plan §1.2's
// "ld00..ld13"/"lt00..lt13" vocabulary exactly. %02d, not a bare int, so
// every bucket's metric name sorts and reads consistently regardless of
// how many bounds latencyBucketBounds ever grows to hold (up to 100).
func latencyDurationMetric(idx int) string {
return fmt.Sprintf("ld%02d", idx)
}
func latencyTTFBMetric(idx int) string {
return fmt.Sprintf("lt%02d", idx)
}
// TTLs applied to counter keys. They exceed their window's natural length
// so a key stays readable for the whole window it belongs to; the bucket
// embedded in the key (see windowKey) already makes a rolled-over window
// use a different key, so these TTLs only bound how long a stale key
// lingers before an opportunistic sweep (memoryStore) or Redis itself
// reclaims it. dayWindowTTL/monthWindowTTL were bumped from their
// original 25h/32d (v0.2 data-layer task): the admin usage-history API's
// day/month charts need a bucket to stay readable for the whole
// retention span a chart can request (historyMaxSpan, admin.go — 35
// daily, 13 monthly buckets), not just long enough to survive its own
// single rollover. The TTL > window-length invariant this comment
// already documented still holds for these consts exactly as applied to
// Redis (35d > 1d, 400d > ~31d, 48h > 1h, 2m > 1m).
//
// That invariant does NOT automatically extend to what memoryStore
// actually applies, though: its own ceiling (memoryStoreMaxTTL, 48h,
// applied only to bound the in-process fallback's live-key count) is
// shorter than a day or month window's natural length on its own. A
// round-1 fix (perf review, 2026-08-21) clamped every fallback TTL to
// that ceiling unconditionally, which broke the invariant for month —
// silently turning TokensPerMonth/CostPerMonthUSD into a rolling ~48h
// budget under the fallback. Round 2 restores it: memoryStore's clampTTL
// never cuts a TTL below enforceTTLFor's window-specific floor, so the
// EFFECTIVE TTL memoryStore ever applies still exceeds its window's
// natural length, exactly like these consts do for Redis — see
// clampTTL/enforceTTLFor/memoryStoreMaxTTL's own doc comments for the
// mechanism.
const (
minWindowTTL = 2 * time.Minute
hourWindowTTL = 48 * time.Hour
dayWindowTTL = 35 * 24 * time.Hour
monthWindowTTL = 400 * 24 * time.Hour
)
// enforceTTLMinWindow/HourWindow/DayWindow/MonthWindow are enforceTTLFor's
// four return values, factored out as named constants so enforceTTLFor and
// enforceTTLsFor (computing all four windows' floors at once instead of one
// enforceTTLFor call per counterIncr) share a single source of truth
// instead of two switches that could silently drift apart.
const (
enforceTTLMinWindow = minWindowTTL
enforceTTLHourWindow = 2 * time.Hour
enforceTTLDayWindow = 25 * time.Hour
enforceTTLMonthWindow = 32 * 24 * time.Hour
)
// usdToMicroFactor scales a USD amount to micro-USD (1e6 micro-USD per
// USD), matching the integer accounting unit used by account and the
// counterStore.
const usdToMicroFactor = 1_000_000
// usdToMicros converts a USD amount to micro-USD, rounded to the nearest
// micro-USD. A bare int64(usd*usdToMicroFactor) truncates rather than
// rounds — float64 can represent a value like 8.2 as very slightly under
// its true value, so a truncating cast turns an intended $8.20 limit into
// 8_199_999 micro-USD instead of 8_200_000, one micro-USD too strict.
func usdToMicros(usd float64) int64 {
return int64(math.Round(usd * usdToMicroFactor))
}
// validate reports an error if lc holds a negative int64 count limit, or a
// cost limit that is negative, NaN, or ±Inf. A nil lc (no limits
// configured) is valid. Without this check, a negative int64 limit would
// silently behave as unlimited — every enforcement path below treats
// limit<=0 as "no limit" — and a NaN/Inf cost limit would propagate
// through usdToMicros into meaningless comparisons instead of failing
// loudly at construction time. Called by newAuthStore for every group's
// and every user's LimitsConfig, so a malformed limit is a constructor
// error rather than a silent misconfiguration.
func (lc *LimitsConfig) validate() error {
if lc == nil {
return nil
}
counts := []struct {
name string
v int64
}{
{"requestsPerMinute", lc.RequestsPerMinute},
{"requestsPerDay", lc.RequestsPerDay},
{"tokensPerDay", lc.TokensPerDay},
{"tokensPerMonth", lc.TokensPerMonth},
}
for _, c := range counts {
if c.v < 0 {
return fmt.Errorf("llmgateway: limits: %s must not be negative, got %d", c.name, c.v)
}
}
costs := []struct {
name string
v float64
}{
{"costPerDayUSD", lc.CostPerDayUSD},
{"costPerMonthUSD", lc.CostPerMonthUSD},
}
for _, c := range costs {
if math.IsNaN(c.v) || math.IsInf(c.v, 0) {
return fmt.Errorf("llmgateway: limits: %s must be a finite number, got %v", c.name, c.v)
}
if c.v < 0 {
return fmt.Errorf("llmgateway: limits: %s must not be negative, got %v", c.name, c.v)
}
}
return nil
}
// counterIncr is one entry in an incrMulti batch: increment key by delta,
// creating it with an expiry of ttl from now if it does not exist or has
// expired.
type counterIncr struct {
key string
delta int64
ttl time.Duration
// enforceTTL is the minimum TTL window's own enforcement correctness
// requires (enforceTTLFor) — the floor memoryStore's clampTTL never
// cuts below, even when memoryStoreMaxTTL's history-retention ceiling
// is lower (review round 2, 2026-08-21, fixing a bug in round 1's
// single-floor clamp: unconditionally capping every TTL at 48h turned
// TokensPerMonth/CostPerMonthUSD into a rolling ~48h budget under the
// fallback, since a month counter's own key would expire and reset
// roughly every two days instead of once a month). redisStore ignores
// this field entirely — ttl is applied in full there; the ceiling and
// this floor only ever matter for the in-process fallback.
enforceTTL time.Duration
// absolute marks this entry as a plain SET, not an INCRBY: delta is
// the exact value to write (e.g. a unix-second timestamp), not an
// amount to add. Added for the last-seen counter family (Q6,
// DECISIONS): a last-seen value is "when did we last see this scope",
// which an increment can never express. redisStore.incrMulti/
// incrAndGetMulti build a single "SET key delta EX secs" command for
// an absolute entry instead of INCRBY(+EXPIRE) — see their own doc
// comments — and memoryStore.applySet mirrors that: unconditionally
// overwrite, never add. false (the zero value) is every existing
// entry's own behavior, unchanged.
absolute bool
}
// counterStore is the storage backend the limiter uses for atomic windowed
// counters. memoryStore (below) is the in-process fallback; a distributed
// (e.g. Redis-backed) implementation is wired in a later task.
//
// counterStore had single-key incrBy/get methods until the 2026-09 deadcode
// audit: checkAndCount/account moved onto the batched incrMulti/getMulti/
// incrAndGetMulti below in the 2026-08-21/22 perf reviews, which left
// incrBy/get with no production caller of any kind — every remaining call
// site was a test exercising a store implementation directly. They were
// removed from the interface and from memoryStore/redisStore; 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 and
// redis_store_test.go's incrOne/getOne).
type counterStore interface {
// getMulti reads every key in keys in one round trip where the
// backend supports it (redisStore pipelines via respClient.getBatch;
// memoryStore's in-process map needs no such optimization but
// implements the same contract for interface conformance), returning
// one value per key in the same order — 0 for a key that does not
// exist or has expired, matching get's contract. An error fails the
// whole batch (mirrored by the limiter's storeGetMulti as a single
// fail-open/fail-closed decision), never a partial result.
getMulti(keys []string) ([]int64, error)
// incrMulti applies counterIncr's per-entry contract to every entry in
// entries in one round trip where the backend supports it (redisStore
// pipelines one INCRBY+EXPIRE pair per entry into a single pipeline
// call; memoryStore's in-process map needs no such optimization but
// implements the same contract for interface conformance), returning
// each entry's new counter value in the same order (perf review,
// 2026-08-21: account previously paid one round trip per counter — up
// to 27 for a 3-scope request with all three metrics nonzero — this
// collapses it down to one; checkAndCount's own increments moved on to
// incrAndGetMulti below, perf review round 3, 2026-08-22). An error
// fails the whole batch, never a partial result, mirrored by the
// limiter's storeIncrMulti as a single fail-open/fail-closed decision,
// the same shape storeGetMulti already applies to a batch read.
incrMulti(entries []counterIncr) ([]int64, error)
// incrAndGetMulti applies incrMulti's own per-entry contract to every
// entry in entries AND reads every key in reads, in ONE round trip
// where the backend supports it (redisStore pipelines every entry's
// INCRBY+EXPIRE pair together with every read key's GET into a single
// pipeline call; memoryStore's in-process map needs no such
// optimization but implements the same contract for interface
// conformance) — fusing checkAndCount's request-counter increments
// with its own token/cost budget reads into the single round trip
// that used to cost up to 9 separate ones for a 2-scope request, both
// fully limited (perf review round 3, 2026-08-22: reads carries the
// tokin/tokout/cost keys checkAndCount's own evaluateScope helper used
// to read via its own separate storeGetMulti calls — see
// buildBudgetProbes, below). This is safe because every read key is
// DISJOINT from every entry's key (req:* vs tokin:*/tokout:*/cost:*),
// and tokens/cost are only ever WRITTEN by account() after the
// upstream response completes — never by this call — so there is no
// read-after-write ordering hazard in reading them alongside the
// increments. Returns incrMulti's own per-entry post-increment values
// and getMulti's own per-key values (0 for a missing/expired key),
// each in entries'/reads' own order. An error fails the whole batch,
// mirrored by the limiter's storeIncrAndGetMulti as one
// fail-open/fail-closed decision, the same shape storeIncrMulti/
// storeGetMulti already apply to their own separate batches.
incrAndGetMulti(entries []counterIncr, reads []string) (incrVals, readVals []int64, err error)
}
// memoryEntry is one counter's value and expiry in memoryStore.
type memoryEntry struct {
expiry time.Time
value int64
}
// sweepEvery is the minimum real time between memoryStore's opportunistic
// expired-entry sweeps, regardless of how many keys memoryStore holds. A
// size-gated sweep (only above N keys) was measured to scan the whole map
// on every increment once live keys exceeded that threshold, since a sweep
// that frees nothing (all keys still live) never brings the count back
// down — a 167x increment cliff. Gating on elapsed time instead bounds sweep
// frequency independent of key count: worst case is one full-map scan
// every 30s.
const sweepEvery = 30 * time.Second
// memoryStoreMaxTTL caps how long memoryStore keeps a counter alive
// purely for HISTORY-chart purposes, when that is safe to shorten.
// Ruling (perf review round 1, 2026-08-21): the in-process fallback's
// long-tail retention is continuity for charting, not enforcement itself
// — applying the real hourWindowTTL/dayWindowTTL/monthWindowTTL values
// (48h/35d/400d) to the fallback map was measured to grow its live key
// count roughly 30x over the original three-window (min/day/month)
// shape, and sweepEvery's own doc comment above already records that a
// full-map sweep which frees nothing is a "167x incrBy cliff" once live
// keys cross a threshold — growing the map ~30x bigger multiplies that
// same cliff's cost by roughly the same factor.
//
// This ceiling must never win over a window's own enforcement floor,
// though (round 2, 2026-08-21 — corrects a round-1 bug): clampTTL applies
// this ceiling AND enforceTTLFor's floor together, and the floor always
// wins where the two would conflict. Concretely: min/hour/day windows sit
// entirely under 48h already, so they are unaffected; month's own floor
// (enforceTTLFor's 32*24h) sits above this ceiling, so a month counter on
// the fallback keeps its full ~32-day life (1-2 live keys per scope/
// metric — negligible next to the day-key savings that motivated this
// ceiling in the first place) instead of expiring every 48h. Only the
// CHARTING tail — a fallback-only deployment's usage-history buckets
// beyond 48h — is what this ceiling actually shortens; see README's
// "fallback retention" note.
const memoryStoreMaxTTL = 48 * time.Hour
// clampTTL bounds ttl to at most memoryStoreMaxTTL, but never below
// enforceTTL — see both consts'/enforceTTLFor's own doc comments for why
// the floor must always win a conflict with the ceiling. enforceTTL=0
// (incrBy's own single-key path, which has no window to derive a floor
// from) means "ceiling only, no floor" — identical to memoryStore's
// pre-round-2 behavior for that path.
func clampTTL(ttl, enforceTTL time.Duration) time.Duration {
if ttl > memoryStoreMaxTTL {
ttl = memoryStoreMaxTTL
}
if ttl < enforceTTL {
ttl = enforceTTL
}
return ttl
}
// memoryStore is an in-process counterStore: a mutex-guarded map with
// per-key expiry. It has no background goroutine — a Yaegi middleware
// instance is rebuilt on every config reload, and any ticker or goroutine
// started here would leak on rebuild instead of being collected with the
// rest of the old instance. Expired entries are instead swept
// opportunistically from applyIncr, at most once every sweepEvery.
type memoryStore struct {
data map[string]*memoryEntry
nowFn func() time.Time // injected for tests; defaults to time.Now
lastSweep time.Time // guarded by mu; zero value sweeps on the first applyIncr
mu sync.Mutex
}
// newMemoryStore returns an empty memoryStore.
func newMemoryStore() *memoryStore {
return &memoryStore{data: make(map[string]*memoryEntry), nowFn: time.Now}
}
// now returns m's current time, via nowFn.
func (m *memoryStore) now() time.Time {
return m.nowFn()
}
// applyIncr increments key by n — creating it fresh, expiring at
// now+ttl, if absent or already expired — and returns its new value. ttl
// must already be clamped by the caller: incrMulti clamps per entry with
// that entry's own enforceTTL floor (see clampTTL); the test-only incrBy
// helper (limits_helpers_test.go, restoring the single-key API removed
// from production in the 2026-09 deadcode audit — checkAndCount/account
// moved onto the batched incrMulti below in the 2026-08-21 perf review,
// leaving incrBy with no production caller) clamps with no enforcement
// floor, since a bare key carries no (kind, id, metric, window) tuple to
// derive one from. Sharing this one map-mutation-plus-opportunistic-sweep
// implementation between both callers means the
// sweep logic, and the "already expired counts as absent" rule, exist
// exactly once.
func (m *memoryStore) applyIncr(key string, n int64, ttl time.Duration) int64 {
now := m.now()
m.mu.Lock()
defer m.mu.Unlock()
if now.Sub(m.lastSweep) >= sweepEvery {
m.sweepLocked(now)
m.lastSweep = now
}
e, ok := m.data[key]
if !ok || !now.Before(e.expiry) {
e = &memoryEntry{expiry: now.Add(ttl)}
m.data[key] = e
}
e.value += n
return e.value
}
// applySet sets key's value to v and its expiry to now+ttl, unconditionally
// — creating the entry if absent, overwriting it if present — memoryStore's
// counterpart to Redis's SET key v EX secs (an absolute counterIncr entry's
// own command, redisStore.incrMulti). Unlike applyIncr, this never adds to
// an existing value and never treats "already expired" specially: every
// call simply replaces whatever was there, exactly like a real SET does.
func (m *memoryStore) applySet(key string, v int64, ttl time.Duration) int64 {
now := m.now()
m.mu.Lock()
defer m.mu.Unlock()
if now.Sub(m.lastSweep) >= sweepEvery {
m.sweepLocked(now)
m.lastSweep = now
}
m.data[key] = &memoryEntry{expiry: now.Add(ttl), value: v}
return v
}
// get implements counterStore.
func (m *memoryStore) get(key string) (int64, error) {
now := m.now()
m.mu.Lock()
defer m.mu.Unlock()
e, ok := m.data[key]
if !ok || !now.Before(e.expiry) {
return 0, nil
}
return e.value, nil
}
// getMulti implements counterStore by looping over get: memoryStore's
// in-process map access is already effectively free per key, so there is
// no round-trip cost to batch away — this exists purely so memoryStore
// satisfies counterStore's getMulti contract for the limiter's nil-store
// fallback path (storeGetMulti).
func (m *memoryStore) getMulti(keys []string) ([]int64, error) {
out := make([]int64, len(keys))
for i, k := range keys {
out[i], _ = m.get(k) // memoryStore.get never errors
}
return out, nil
}
// incrMulti implements counterStore by looping over applyIncr:
// memoryStore's in-process map access is already effectively free per
// key, so there is no round-trip cost to batch away — this exists purely
// so memoryStore satisfies counterStore's incrMulti contract for the
// limiter's nil-store fallback path (storeIncrMulti). Each entry's own
// ttl is clamped with ITS OWN enforceTTL floor (clampTTL), not incrBy's
// floor-less clamp — this is the window-aware path real enforcement
// (checkAndCount/account) relies on; see counterIncr.enforceTTL's own
// doc comment for the bug this fixes.
func (m *memoryStore) incrMulti(entries []counterIncr) ([]int64, error) {
out := make([]int64, len(entries))
for i, e := range entries {
if e.absolute {
out[i] = m.applySet(e.key, e.delta, clampTTL(e.ttl, e.enforceTTL))
continue
}
out[i] = m.applyIncr(e.key, e.delta, clampTTL(e.ttl, e.enforceTTL))
}
return out, nil
}
// incrAndGetMulti implements counterStore by composing incrMulti and
// getMulti: memoryStore's in-process map access is already effectively
// free per key, so there is no round-trip cost to fuse away here — this
// exists purely so memoryStore satisfies counterStore's incrAndGetMulti
// contract (perf review round 3, 2026-08-22) for the limiter's nil-store
// fallback path and its own failOpen fallback, mirroring incrMulti/
// getMulti's own "implements the contract, buys nothing locally" role.
func (m *memoryStore) incrAndGetMulti(entries []counterIncr, reads []string) ([]int64, []int64, error) {
incrVals, _ := m.incrMulti(entries) // memoryStore.incrMulti never errors
readVals, _ := m.getMulti(reads) // memoryStore.getMulti never errors
return incrVals, readVals, nil
}
// sweepLocked deletes every expired entry. Callers must hold m.mu.
func (m *memoryStore) sweepLocked(now time.Time) {
for k, e := range m.data {
if !now.Before(e.expiry) {
delete(m.data, k)
}
}
}
// usage holds one request's token counts. estimated marks counts derived
// from a tokenizer estimate rather than the provider's reported usage
// (e.g. for a streaming response cut off before an upstream usage frame
// arrived).
type usage struct {
prompt, completion int64
// estimated is true when prompt/completion were derived from a
// tokenizer estimate (request body size ÷ 4) rather than the
// provider's own reported usage. routes_unified.go's runUnified reads
// this to log the estimate explicitly, so a caller reading logs can
// tell an estimated cost from a provider-reported one, per the spec's
// "logged as estimated" requirement.
estimated bool
}
// total returns the request's combined prompt and completion tokens.
func (u usage) total() int64 {
return u.prompt + u.completion
}
// limitScope is one entity (a user, their group, or the synthetic total
// scope — see totalScopeKind) whose limits apply to a request.
// checkAndCount and account evaluate every scope in the slice they are
// given, so a request is counted and checked against both its user's and
// its group's limits in one call.
type limitScope struct {
limits *LimitsConfig
kind string // "user", "group", or "total"
id string
}
// totalScopeKind and totalScopeID name the synthetic "all LLM traffic"
// scope withTotalScope (routes_unified.go) appends to every metered
// route's scopes slice (v0.2 data-layer task): {kind: totalScopeKind, id:
// totalScopeID}, limits always nil. checkAndCount's own nil-limits check
// (below) skips it during evaluation, but still counts it
// like any other scope — its req/tokin/tokout/cost counters accumulate
// every LLM request across all users and groups combined, the admin
// usage-history "total" series — but no configuration can ever throttle
// traffic in its name.
const (
totalScopeKind = "total"
totalScopeID = "all"
)
// limitViolation describes the first limit a request breached.
type limitViolation struct {
message string
// kind is one of the eventKindRateLimit/eventKindBudget/
// eventKindStoreDown vocabulary (events.go, F3 v0.3 dashboard task) —
// set once, at construction (storeDownViolation, requestLimitViolation,
// checkAndCount's own budget-probe branch), so recordLimitEvent can
// classify GET /admin/api/events' Kind field straight from it instead
// of re-deriving the same distinction from v.message/v.storeDown a
// second time.
kind string
// retryAfter is the number of seconds until the violated window ends
// — until the client can plausibly succeed again. Left at its zero
// value for a storeDown violation, which carries no meaningful window.
retryAfter int
// storeDown reports whether the violation was manufactured because the
// configured counterStore was unreachable and failOpen is false,
// rather than an actual limit breach. Callers map this to an HTTP 503
// instead of 429 (Task 12).
storeDown bool
}
// lastSeenGateThrottle is the minimum real time between two last-seen SET
// writes THIS replica issues for the identical (kind, id) scope (Q6,
// DECISIONS: "last-seen via absolute SET entries in the admission
// pipeline, throttled 60s/replica/scope"). An absolute SET is idempotent
// and safe to send on every admitted request, but doing so would add a
// synchronous store round-trip cost proportional to request volume for a
// value nothing needs updated more often than this.
const lastSeenGateThrottle = 60 * time.Second
// lastSeenGateMapCapBase is lastSeenGate.capacity's starting value, and
// the floor it never shrinks below — the same generation-rotation shape
// rejectionCounter/authFailureTracker already use, and for the identical
// reason (rejectionCounterMapCap's own doc comment): a hot-reloaded
// user/group set can churn names over a long process lifetime, and
// nothing here should grow this map without bound for a renamed or
// deleted scope. A scope displaced by rotation simply writes its next
// last-seen SET a little early — never a correctness bug, only an extra
// write, the same trade-off rejectionCounter's own doc comment already
// accepts.
const lastSeenGateMapCapBase = 4096
// lastSeenGateMapCapMax is the hard ceiling lastSeenGate.capacity's own
// adaptive growth (below) never exceeds — bounding this replica's total
// last-seen throttle memory at, worst case, two generations of
// lastSeenGateMapCapMax entries (current + previous) regardless of how
// many distinct scopes this process ever admits. N5 (verify-redesign-
// final.md): a fixed 4,096 cap throttled correctly up to ~2x itself
// (~8,192 live scopes, current+previous both full), but a live scope
// count ABOVE that rotated current before lastSeenGateThrottle could
// ever elapse for most of it, discarding still-fresh writes on every
// rotation and degrading to one write per admission — the throttle it
// exists to provide. Past this ceiling the gate degrades gracefully back
// to the same fixed-size rotate/discard behaviour a below-ceiling gate
// always had, rather than growing without bound.
const lastSeenGateMapCapMax = 65536
// lastSeenGate throttles checkAndCount's own last-seen SET writes
// (lastSeenEntries, below) per (kind, id) scope, per replica: due reports
// true (and records now) only when this replica has never written one for
// that scope, or its last write was more than lastSeenGateThrottle ago.
// Guarded by mu since checkAndCount runs on every request's own goroutine
// concurrently.
//
// capacity is the CURRENT rotation threshold (starts at
// lastSeenGateMapCapBase, only ever grows, up to lastSeenGateMapCapMax —
// see due's own doc comment for when and why it grows) rather than the
// fixed lastSeenGateMapCap constant an earlier version used.
type lastSeenGate struct {
current map[string]int64 // kind+"\x00"+id -> unix seconds of this replica's last write
previous map[string]int64
lastRotate time.Time // zero until the first rotation; read/written under mu, same as the two maps
capacity int
mu sync.Mutex
}
// due reports whether this replica should write a fresh last-seen SET for
// (kind, id) at now, recording now as that scope's new last-write time
// when it does.
//
// Adaptive capacity (N5 fix, verify-redesign-final.md): when current
// fills past g.capacity, due does not always rotate. It rotates (moving
// current to previous, starting a fresh current) only on that scope's
// FIRST overflow, or once at least lastSeenGateThrottle has passed since
// the previous rotation — the same signal a healthy, below-capacity gate
// already relies on (a population that fits comfortably takes at least
// that long to refill current). When current instead refills WITHIN one
// throttle window of the last rotation, that is direct evidence the live
// scope population exceeds 2x the current capacity: rotating now would
// immediately discard `previous`'s still-fresh writes and start evicting
// the very entries that just proved the map too small. Growing capacity
// instead (doubled, capped at lastSeenGateMapCapMax) lets current keep
// accumulating past the old threshold without losing anything already
// recorded, so a population that stabilizes below the ceiling ends up
// fully covered by current+previous, and the throttle holds for it the
// same way it always did below ~2x lastSeenGateMapCapBase.
func (g *lastSeenGate) due(kind, id string, now time.Time) bool {
key := kind + "\x00" + id
nowUnix := now.Unix()
g.mu.Lock()
defer g.mu.Unlock()
if g.current == nil {
g.current = make(map[string]int64)
g.capacity = lastSeenGateMapCapBase
}
if last, ok := g.current[key]; ok {
if nowUnix-last < int64(lastSeenGateThrottle/time.Second) {
return false
}
} else if last, ok := g.previous[key]; ok {
// P8 fix (admin dashboard redesign verify round): a scope
// written before the last rotation must still throttle against
// ITS OWN last write instead of being treated as brand new just
// because current rotated out from under it — previous existed
// for exactly this lookup, but nothing ever consulted it before
// this fix, so every scope's throttle silently reset on its
// first request after any rotation (i.e. constantly, once the
// map holds more than g.capacity active scopes). Copy the found
// timestamp forward into current so the NEXT lookup for this key
// hits the fast path above; previous is left otherwise untouched
// (it keeps answering for every other not-yet-migrated key until
// the next rotation replaces it).
if nowUnix-last < int64(lastSeenGateThrottle/time.Second) {
g.current[key] = last
return false
}
} else if len(g.current) >= g.capacity {
if g.capacity < lastSeenGateMapCapMax && !g.lastRotate.IsZero() && now.Sub(g.lastRotate) < lastSeenGateThrottle {
g.capacity *= 2
if g.capacity > lastSeenGateMapCapMax {
g.capacity = lastSeenGateMapCapMax
}
} else {
g.previous = g.current
g.current = make(map[string]int64, g.capacity)
g.lastRotate = now
}
}
g.current[key] = nowUnix
return true
}
// lastSeenKey builds the counterStore key an absolute last-seen SET entry
// writes to for (kind, id): "llmgw:seen:{kind}:{id}" — a fixed key with no
// window-bucket component (unlike windowKey/windowKeyForBucket): a
// last-seen timestamp has no natural rollover, so there is exactly one key
// per scope, never one per bucket. Exposed for WP-B's own GET
// /admin/api/usage reader (usageWindowKeys, below, already appends this
// alongside every other per-scope key) to build the identical key.
func lastSeenKey(kind, id string) string {
return "llmgw:seen:" + kind + ":" + id
}
// limiter enforces per-minute/day/month request, token, and cost limits
// using fixed windows keyed by windowKey.
type limiter struct {
// fieldalignment (govet): pointer-shaped fields first, grouped ahead
// of the two time.Time fields and every scalar below — same convention
// providerState/adminProviderView already document (registry.go/
// admin.go).
lastStoreFailure time.Time // guarded by logMu; zero means the store-down latch is not open (see storeLatched)
lastLogAt time.Time // guarded by logMu; last time a store error was logged
store counterStore // configured backend; nil means always use fallback (see newLimiter)
// lastSeen throttles checkAndCount's own last-seen SET writes (Q6,
// DECISIONS) — always constructed by newLimiter (never nil), so every
// limiter built either directly or via newConfiguredLimiter can call
// lastSeenEntries safely regardless of whether admin stats end up
// enabled.
lastSeen *lastSeenGate
fallback *memoryStore // in-process counter store, always available
nowFn func() time.Time // injected for tests; defaults to time.Now
// logf is a bound method value (g.errorf), injectable for tests; defaults
// to a no-op. Its own call site (logStoreError) passes exactly one
// variadic argument — never extend that to two or more without first
// reading modelRegistry.log's doc comment in registry.go: a struct
// field of variadic func type crashes Yaegi v0.16.1's CFG builder past
// one variadic argument, even though the identical call through a
// method or interface method does not.
logf func(format string, args ...any)
// spawn runs f, by default in its own goroutine — the SHOULD-5 ruling
// (v0.22 review round): recordProviderAttempt's store write is
// telemetry-only, and must never sit on a streaming response's
// time-to-first-byte path (a synchronous storeIncrMulti round trip
// there would delay every first chunk by however long that write
// takes). newLimiter always wires a production limiter's spawn to a
// BOUNDED goroutine — see spawnTokens below (FOLDED-1, v0.22 review
// round, round 2): unbounded `go f()` could pile up an unlimited
// number of in-flight writes against a slow-but-alive Redis, since
// nothing here backpressures request volume. A test that needs
// recordProviderAttempt's write to have already landed by the time it
// reads a counter back overrides spawn to run f synchronously
// instead, bypassing spawnTokens entirely — the same dependency-
// injection idiom nowFn already uses on this struct, and waitFn uses
// on retryPolicy (retry.go). A goroutine spawned this way outlives
// the request that triggered it by design: a write still in flight at
// process shutdown is lost. Accepted — this is telemetry, nothing in
// the request path gates on it, and the fixed-window counters it
// writes to are inherently approximate already (a counter reset at a
// bucket boundary loses whatever was in the previous bucket too).
spawn func(func())
// spawnTokens is the buffered-channel semaphore the production spawn
// closure (newLimiter) acquires from non-blockingly before starting a
// goroutine, and releases when that goroutine finishes — FOLDED-1
// (v0.22 review round, round 2): capped at providerAttemptSpawnCap
// in-flight writes per limiter. Acquiring a token that is not
// immediately available means the write is DROPPED, never queued or
// blocked — bounded AND lossy-under-pressure, by design: this is a
// telemetry counter family (recordProviderAttempt), not enforcement
// (checkAndCount/account never go through spawn at all), so losing an
// occasional attempt/failure increment under sustained store pressure
// is an accepted trade-off against the alternative, an unbounded
// goroutine pile-up that could itself become the outage. Per-limiter,
// not a package-level channel: this codebase's own test suite
// constructs many independent *limiter values in one process
// (including in parallel, via t.Parallel()), and a shared global
// semaphore would let one test's write volume starve an unrelated
// test's — see newLimiter's own construction of it.
spawnTokens chan struct{}
// rejections tracks rate/budget-limit-violation counts per scope
// (metrics.go's llmgateway_rate_limit_rejections_total). It is a
// plain in-process, per-process-lifetime counter guarded by its own
// mutex, deliberately NOT a counterStore key: checkAndCount already
// pays a Redis round trip on every admission decision, and every
// number metrics.go otherwise exposes is read back OUT of that
// existing accounting rather than written fresh — but "how many
// requests were rejected" has no existing counter to read at all
// (req:min/req:day count every admission attempt, accepted or not,
// by design — see checkAndCount's own doc comment). Adding it here
// as a bare map increment costs one uncontended lock per rejection,
// never a second network round trip, and never re-derives a number
// the store already tracks.
//
// CORRECTION (adversarial verification, 2026-08-23): an earlier
// version of this comment called the increment "the rare, not-hot-
// path outcome of checkAndCount" — true for an ordinary single-scope
// limit breach, but false for the storeDownViolation branch
// (checkAndCount's own body, below): with a configured store down
// and failOpen false, EVERY request takes that branch, so the
// increment runs on every request for as long as the outage lasts,
// not rarely. The honest claim is narrower: it is still cheaper than
// the network round trip that already failed to produce this
// violation in the first place, never an ADDITIONAL one.
rejections rejectionCounter
// lastErrMsg is the message of the most recent store operation
// failure, guarded by logMu alongside lastStoreFailure. It is never
// cleared on a later success — "last store error" for the admin
// dashboard (spec §4, v0.2) means exactly that, a persisting fact,
// not "is the store currently failing" (storeLatched already answers
// that question for the enforcement path).
lastErrMsg string
logMu sync.Mutex
failOpen bool // store-error policy: true falls back to fallback, false refuses the request
// statsAdmin, statsUserModel, statsLatency gate the admin-redesign
// counter families this task adds (DECISIONS: "Always-on-with-admin"
// vs the two opt-in sub-flags) — set once, by newConfiguredLimiter,
// from Config.Admin/Config.Admin.Stats; a limiter built directly via
// newLimiter (most tests) defaults every one of these to false, so
// existing tests observe byte-identical behavior to before this task
// unless they opt in explicitly.
//
// - statsAdmin: adminEnabled(cfg) — gates every "always-on-with-
// admin" family (last-seen, hourly provider attempt/fail/timeout,
// failover, rej/hour, r402, target x caller, cache counters).
// - statsUserModel: statsAdmin && Config.Admin.Stats.UserModel
// (default off) — gates the opt-in per-(user, model) breakdown.
// - statsLatency: statsAdmin && Config.Admin.Stats.Latency (default
// off) — gates the opt-in per-bucket latency counters. Already
// ANDed with statsAdmin at construction, so a call site need only
// check this one field, never both.
statsAdmin bool
statsUserModel bool
statsLatency bool
}
// rejectionScope is the (kind, id) key rejectionCounter accumulates
// under — a scope's own kind/id pair, exactly as limitScope carries them,
// but held separately so rejectionCounter's map key never aliases a
// limitScope's own `limits` pointer (which plays no part in identity
// here: two limitScope values for the same user with different `limits`
// pointers must still land in the identical bucket).
type rejectionScope struct {
kind string
id string
}
// rejectionCounterMapCap bounds how many distinct (kind, id) scopes
// rejectionCounter.current tracks before rotating (review fix,
// adversarial verification 2026-08-23): replaceFileUsers (auth.go) can
// hot-reload the configured user/group set at any time, and nothing
// here ever removed a scope's entry when the user or group behind it
// was renamed or deleted — over a long-running process's lifetime, a
// deployment that renames or churns users keeps exporting a series FOR
// EVERY NAME EVER SEEN, not just currently configured ones, growing
// this map (and the resulting Prometheus series count) without bound.
// 2000 comfortably exceeds the 1,000-user scale this codebase already
// treats as a real deployment size elsewhere (adminUsageChunkScopes'
// own doc comment, admin.go) with headroom left for renamed-user churn
// and the synthetic store_down entry, while still bounding the worst
// case.
const rejectionCounterMapCap = 2000
// rejectionCounter accumulates rate/budget-limit-violation counts per
// scope for the lifetime of the process — limiter.rejections' own
// backing store; see its doc comment for why this exists as a bare
// in-process map rather than a counterStore key.
//
// current/previous generation-rotation (review fix, adversarial
// verification 2026-08-23) mirrors authFailureTracker's own shape
// (auth.go) for the identical reason: bound total memory without ever
// scanning. Rotation is a two-pointer swap (O(1)), triggered on
// inserting a brand-new scope once current is already at
// rejectionCounterMapCap; total memory is bounded to at most
// 2×rejectionCounterMapCap entries. Unlike authFailureTracker, there is
// no TTL — a Prometheus counter must only ever grow for as long as its
// series is being reported, so a scope's count is never read back out
// of previous once superseded (see snapshot, below): a scope displaced
// by rotation, if it is ever rejected again, starts a fresh count at 1
// rather than resuming its old total. That is a real, visible counter
// reset for that one series — accepted, the same trade-off
// authFailureTracker's own doc comment already accepts for a different
// bounded resource, and one Prometheus's own counter-reset handling in
// rate()/increase() already tolerates (the identical mechanism that
// already tolerates a process restart).
type rejectionCounter struct {
current map[rejectionScope]int64
previous map[rejectionScope]int64
mu sync.Mutex
}
// increment records one rejection for (kind, id), initializing current
// on first use — rejectionCounter's zero value (as embedded, unexported,
// in limiter) is ready to use without a constructor. Rotates current ->
// previous before inserting a brand-new (kind, id) once current is
// already at rejectionCounterMapCap, mirroring authFailureTracker.
// increment's identical shape (auth.go).
func (c *rejectionCounter) increment(kind, id string) {
key := rejectionScope{kind: kind, id: id}
c.mu.Lock()
if c.current == nil {
c.current = make(map[rejectionScope]int64)
}
if _, ok := c.current[key]; !ok && len(c.current) >= rejectionCounterMapCap {
c.previous = c.current
c.current = make(map[rejectionScope]int64, rejectionCounterMapCap)
}
c.current[key]++
c.mu.Unlock()
}
// rejectionSnapshot is one (kind, id) scope's rate-limit-rejection count
// — rejectionCounter.snapshot's own output, metrics.go's read of it.
type rejectionSnapshot struct {
kind string
id string
count int64
}
// snapshot returns every scope in current with at least one recorded
// rejection, in no particular order (metrics.go sorts its own copy
// before rendering). previous is deliberately NOT merged in: a scope
// there was displaced by rotation, and re-reporting its stale, no-
// longer-growing count would misrepresent it as still current.
func (c *rejectionCounter) snapshot() []rejectionSnapshot {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]rejectionSnapshot, 0, len(c.current))
for scope, n := range c.current {
out = append(out, rejectionSnapshot{kind: scope.kind, id: scope.id, count: n})
}
return out
}
// pruneUserScopes deletes every "user"-kind entry from both current and
// previous whose id is not in keep — limiter.pruneRejections' own
// backing implementation; see that method's doc comment for when and
// why this runs. Walking and deleting from a live map mid-range is safe
// in Go (deleting the current key during a range is explicitly
// permitted); previous is included so a recently-rotated, still-stale
// entry for a deleted user does not linger there either.
func (c *rejectionCounter) pruneUserScopes(keep map[string]bool) {
c.mu.Lock()
defer c.mu.Unlock()
for scope := range c.current {
if scope.kind == "user" && !keep[scope.id] {
delete(c.current, scope)
}
}
for scope := range c.previous {
if scope.kind == "user" && !keep[scope.id] {
delete(c.previous, scope)
}
}
}
// rejectionScopeStoreDown is the synthetic scope id checkAndCount
// records a rejection under when it refuses a request because the
// configured store is unreachable and failOpen is false
// (storeDownViolation) — there is no real limitScope in play at that
// point (the failure happens before any scope is evaluated), so this
// names the event on its own rather than attributing it to whichever
// scope happened to be first in the slice.
const rejectionScopeStoreDown = "store_down"
// rejectionSnapshot (method) reads l.rejections — metrics.go's own entry
// point, named to match l's other read accessors (redisStatus,
// currentUsage, providerUsage) rather than exposing the rejections field
// itself.
func (l *limiter) rejectionSnapshot() []rejectionSnapshot {
return l.rejections.snapshot()
}
// pruneRejections removes every "user"-kind rejection scope whose id is
// not in keep (review fix, adversarial verification 2026-08-23): a user
// removed from a hot-reloaded users file (auth.go's replaceFileUsers)
// must not keep exporting llmgateway_rate_limit_rejections_total
// forever. Called only from ServeHTTP, and only when authStore.
// maybeReload just reported a REAL reload — see that method's own doc
// comment (users_file.go) for why this must never run on every request.
// Scope kinds other than "user" (group, the synthetic total and
// store_down entries) are left untouched: groups and the total scope
// are never hot-reloaded (Config.Groups is fixed at construction), and
// store_down names no real scope at all.
func (l *limiter) pruneRejections(keep map[string]bool) {
l.rejections.pruneUserScopes(keep)
}
// providerAttemptSpawnCap bounds how many concurrent recordProviderAttempt
// store writes one limiter's production spawn implementation will run at
// once (FOLDED-1, v0.22 review round, round 2) — see limiter.spawnTokens'
// own doc comment for the full rationale. 64 is generous headroom for
// this telemetry path specifically (every real deployment's traffic
// volume is expected to sit well under it in steady state) while still
// bounding the worst case against a store that has gone slow but not
// down.
const providerAttemptSpawnCap = 64
// newLimiter returns a limiter. A nil store means every operation uses the
// limiter's own in-process fallback memoryStore — the plugin still
// enforces limits with no distributed backend configured, just without
// sharing counters across Traefik instances. failOpen governs what happens
// when a non-nil store errors: true silently falls back to the in-process
// memoryStore for that operation, false refuses the request (see
// storeIncrMulti / storeGetMulti).
//
// Known limitation, documented rather than fixed (verify-core review,
// round 4, finding 8 — accepted as a cost too high for the benefit): with
// failOpen=true, EVERY Traefik instance running this plugin falls back to
// its OWN private, unshared memoryStore for the whole duration of a
// configured store outage (storeLatched's 5s latch, re-opened on each new
// failure). Two instances behind the same load balancer therefore each
// enforce limits against a DIFFERENT count for the same scope during that
// window — user/group/total totals visibly drift apart across backends —
// and neither fallback's counts are reconciled back into the shared store
// once it recovers; each instance's fallback simply stops being read
// again. This under-enforces (each instance only sees its own share of
// traffic against the full limit) rather than over-enforces, and is
// bounded to the outage window itself, so it is accepted as a fail-open
// trade-off rather than fixed — a cross-instance reconciliation pass
// would need either a persistent pending-delta queue replayed into the
// store on recovery, or a broadcast mechanism between instances, either
// of which is a materially larger change than this review's scope.
func newLimiter(store counterStore, failOpen bool) *limiter {
l := &limiter{
store: store,
fallback: newMemoryStore(),
nowFn: time.Now,
logf: func(string, ...any) {},
failOpen: failOpen,
spawnTokens: make(chan struct{}, providerAttemptSpawnCap),
lastSeen: &lastSeenGate{},
}
l.spawn = func(f func()) {
select {
case l.spawnTokens <- struct{}{}:
go func() {
// The deferred recover is required for the same reason
// modelRegistry.refreshProvider documents (registry.go):
// this runs off any request's goroutine, so an unrecovered
// panic has no ServeHTTP caller to unwind into and would
// crash the whole Traefik process — every router, not just
// this middleware. A panicking telemetry write must cost
// exactly one dropped write.
defer func() {
if rec := recover(); rec != nil {
l.logf("limit telemetry write panicked: %v", rec)
}
<-l.spawnTokens
}()
f()
}()
default:
// Dropped: spawnTokens is full — bounded, lossy-under-pressure
// by design (FOLDED-1, its own doc comment above). The drop is
// logged (rate-limited) so a stressed store under-reporting
// provider health is distinguishable from a healthy provider —
// silence here would make the two identical.
l.logSpawnDrop()
}
}
return l
}
// now returns the limiter's current time, via nowFn.
func (l *limiter) now() time.Time {
return l.nowFn()
}
// logSpawnDrop logs a dropped telemetry write, rate-limited on the same
// clock and interval as logStoreError (sharing lastLogAt deliberately:
// drops and store errors are the same "the store is struggling" story,
// and one line per storeErrorLogEvery is enough to tell it).
func (l *limiter) logSpawnDrop() {
now := l.now()
l.logMu.Lock()
shouldLog := now.Sub(l.lastLogAt) >= storeErrorLogEvery
if shouldLog {
l.lastLogAt = now
}
l.logMu.Unlock()
if shouldLog {
l.logf("limit telemetry write dropped: %d concurrent writes in flight (bounded, lossy under pressure by design)", providerAttemptSpawnCap)
}
}
// logStoreError logs err via l.logf, rate-limited to once per
// storeErrorLogEvery regardless of how many store operations fail in
// between.
func (l *limiter) logStoreError(err error) {
now := l.now()
l.logMu.Lock()
shouldLog := now.Sub(l.lastLogAt) >= storeErrorLogEvery
if shouldLog {
l.lastLogAt = now
}
l.logMu.Unlock()
if shouldLog {
l.logf("limit store error: %v", err)
}
}
// storeLatched reports whether l is within storeDownLatchFor of its last
// recorded store failure. When true, storeIncrMulti/storeGetMulti/
// storeIncrAndGetMulti must skip the network call entirely and go straight
// to the fail-open/fail-closed
// policy — the same outcome a fresh call would reach anyway, just without
// paying respCallTimeout again to find out. Guarded by logMu, the same
// mutex lastLogAt already uses for this kind of small timestamp state.
func (l *limiter) storeLatched() bool {
l.logMu.Lock()
defer l.logMu.Unlock()
return !l.lastStoreFailure.IsZero() && l.now().Sub(l.lastStoreFailure) < storeDownLatchFor
}
// configuredStoreDown reports whether l has a configured store that is
// currently within its failure latch (storeLatched) — meaning any read
// made right now is served from the in-process fallback, never the
// real, shared store, REGARDLESS of what storeGetMulti's own ok return
// says. l.store == nil (no store configured at all — a fallback-only
// deployment) always reports false: there, the fallback IS the source
// of truth by design, nothing has degraded.
//
// This exists because storeGetMulti's ok=true does not distinguish "the
// store answered for real" from "the store is down, failOpen fell back
// to the empty in-process fallback, which answered with zeros" — and
// failOpen defaults to true (newConfiguredLimiter, llmgateway.go). A
// caller that only checks ok, as currentUsage/providerUsage's callers
// used to, reports a fail-OPEN store outage as confirmed real usage:
// exactly the fabricated-counter-reset bug metrics.go's storeDown skip
// was built to prevent, except on the DEFAULT config, where it was
// never actually reachable (review fix, adversarial verification
// 2026-08-23) — see currentUsage/providerUsage's own doc comments for
// where this is now checked.
func (l *limiter) configuredStoreDown() bool {
return l.store != nil && l.storeLatched()
}
// recordStoreFailure logs err (rate-limited, see logStoreError) and opens
// the store-down latch: every storeIncrMulti/storeGetMulti/
// storeIncrAndGetMulti call for the next storeDownLatchFor skips the
// network call and applies the fail-open/fail-closed policy directly.
func (l *limiter) recordStoreFailure(err error) {
l.logStoreError(err)
l.logMu.Lock()
l.lastStoreFailure = l.now()
l.lastErrMsg = err.Error()
l.logMu.Unlock()
}
// redisStatus reports whether l has a configured (non-fallback-only)
// store, that store's most recent operation failure message, and when
// that failure happened — the admin dashboard's redis status line (spec
// §4, v0.2). lastErr is "" and lastErrAt is the zero time when no store
// operation has ever failed. Returning the timestamp alongside the
// message (controller-approved amendment, 2026-08-20 review) lets the
// dashboard render "last error (12s ago): ..." instead of a bare message
// that reads as "currently broken" even long after a single transient
// blip. Guarded by logMu, the same mutex lastStoreFailure/lastErrMsg
// already use.
func (l *limiter) redisStatus() (configured bool, lastErr string, lastErrAt time.Time) {
l.logMu.Lock()
defer l.logMu.Unlock()
return l.store != nil, l.lastErrMsg, l.lastStoreFailure
}
// storeGetMulti applies the limiter's fail-open/fail-closed/latched
// policy to a batch read: one round trip against the configured store (or
// the in-process fallback) for the whole slice of keys. ok is false only
// in the fail-closed case — a caller must not read the returned slice as
// real values when ok is false.
func (l *limiter) storeGetMulti(keys []string) (v []int64, ok bool) {
if l.store == nil {
v, _ = l.fallback.getMulti(keys) // fallback never errors
return v, true
}
if l.storeLatched() {
return l.failPolicyGetMulti(keys)
}
v, err := l.store.getMulti(keys)
if err == nil {
return v, true
}
l.recordStoreFailure(err)
return l.failPolicyGetMulti(keys)
}
// failPolicyGetMulti applies the limiter's fail-open/fail-closed policy
// for a batch read the caller has decided not to attempt against the
// configured store (it just failed, or the store-down latch is open):
// failOpen=true reads through to the fallback instead; failOpen=false
// reports the operation as failed.
func (l *limiter) failPolicyGetMulti(keys []string) ([]int64, bool) {
if !l.failOpen {
return nil, false
}
v, _ := l.fallback.getMulti(keys)
return v, true
}
// storeIncrMulti mirrors storeGetMulti for a batch of increments: one round
// trip against the configured store (or the in-process fallback) for the
// whole slice, applying the identical fail-open/fail-closed/latched
// policy storeGetMulti applies per batch. ok is false only in the fail-closed
// case — a caller must drop the sample (account) or skip the write
// (countTargetRequests, recordProviderAttempt) for that, rather than
// treating a nil/short slice as real counter readings. checkAndCount's own
// increments moved to storeIncrAndGetMulti (perf review round 3,
// 2026-08-22), so it is no longer a caller of this method.
func (l *limiter) storeIncrMulti(entries []counterIncr) (v []int64, ok bool) {
if l.store == nil {
v, _ = l.fallback.incrMulti(entries) // fallback never errors
return v, true
}
if l.storeLatched() {
return l.failPolicyIncrMulti(entries)
}
v, err := l.store.incrMulti(entries)
if err == nil {
return v, true
}
l.recordStoreFailure(err)
return l.failPolicyIncrMulti(entries)
}
// failPolicyIncrMulti mirrors failPolicyGetMulti for a batch increment.
func (l *limiter) failPolicyIncrMulti(entries []counterIncr) ([]int64, bool) {
if !l.failOpen {
return nil, false
}
v, _ := l.fallback.incrMulti(entries)
return v, true
}
// storeIncrAndGetMulti mirrors storeIncrMulti for checkAndCount's fused
// admission round trip (perf review round 3, 2026-08-22): one call against
// the configured store (or the in-process fallback) for BOTH entries' own
// increments and reads' own current values, applying the identical
// fail-open/fail-closed/latched policy storeIncrMulti/storeGetMulti already
// apply to their own separate batches. ok is false only in the fail-closed
// case — checkAndCount must refuse the request for that, rather than
// treating a nil/short slice as real counter readings.
func (l *limiter) storeIncrAndGetMulti(entries []counterIncr, reads []string) (incrVals, readVals []int64, ok bool) {
if l.store == nil {
incrVals, readVals, _ = l.fallback.incrAndGetMulti(entries, reads) // fallback never errors
return incrVals, readVals, true
}
if l.storeLatched() {
return l.failPolicyIncrAndGetMulti(entries, reads)
}
incrVals, readVals, err := l.store.incrAndGetMulti(entries, reads)
if err == nil {
return incrVals, readVals, true
}
l.recordStoreFailure(err)
return l.failPolicyIncrAndGetMulti(entries, reads)
}
// failPolicyIncrAndGetMulti mirrors failPolicyIncrMulti for the fused
// admission round trip.
func (l *limiter) failPolicyIncrAndGetMulti(entries []counterIncr, reads []string) ([]int64, []int64, bool) {
if !l.failOpen {
return nil, nil, false
}
incrVals, readVals, _ := l.fallback.incrAndGetMulti(entries, reads)
return incrVals, readVals, true
}
// countAsync spawns entries as one fire-and-forget storeIncrMulti batch,
// off the request's hot path (l.spawn) — the shared primitive behind every
// async-only counter family this task adds: a cache HIT's chit/csave
// (recordCacheHit) and a refused-unpriced-model's r402 (recordUnpriced402),
// mirroring recordProviderAttempt's own long-established "build entries,
// then spawn the write" shape, factored out here so it is written once. A
// nil/empty entries slice spawns nothing at all.
func (l *limiter) countAsync(entries []counterIncr) {
if len(entries) == 0 {
return
}
l.spawn(func() {
l.storeIncrMulti(entries)
})
}
// storeDownViolation is the violation checkAndCount returns when the
// configured store is unreachable and failOpen is false: the
// request is refused instead of silently enforcing limits against a
// non-shared fallback, so a backend outage cannot let every configured
// limit go unenforced across a fleet of gateway instances.
func storeDownViolation() *limitViolation {
return &limitViolation{message: "limit store unavailable", storeDown: true, kind: eventKindStoreDown}
}
// bucketFor formats t (in UTC) to window's bucket granularity: the string
// windowKey embeds as a key's final component, and the same string GET
// /admin/api/usage/history echoes as each point's "bucket" field
// (historyBucketKeys, below).
func bucketFor(t time.Time, window string) string {
u := t.UTC()
switch window {
case windowHour:
return u.Format("2006010215")
case windowMin:
return u.Format("200601021504")
case windowDay:
return u.Format("20060102")
case windowMonth:
return u.Format("200601")
default:
// window is always one of the four constants above, set by this
// file's own callers, never by request input — an unknown value
// here is a programming error. ServeHTTP's recoverPanic turns
// this into a logged 500 instead of crashing the process.
panic(fmt.Sprintf("llmgateway: bucketFor: unknown window %q", window))
}
}
// windowBuckets holds all four windows' bucketFor strings for one instant,
// as bucketsFor (below) computes them: checkAndCount/account/
// countTargetRequests/recordProviderAttempt each need several of these
// together (never just one) and call bucketsFor(now) exactly once per
// call, computing every bucket it needs once — instead of once per
// counterIncr, as windowKey/bucketFor did directly before this type
// existed.
type windowBuckets struct {
min, hour, day, month string
}
// bucketsFor computes t's four window buckets via bucketFor, once each,
// for a caller that needs several of them together.
func bucketsFor(t time.Time) windowBuckets {
return windowBuckets{
min: bucketFor(t, windowMin),
hour: bucketFor(t, windowHour),
day: bucketFor(t, windowDay),
month: bucketFor(t, windowMonth),
}
}
// enforceTTLSet holds all four windows' enforceTTLFor floors together, the
// TTL-side counterpart to windowBuckets — built fresh by enforceTTLsFor on
// every call (unlike windowBuckets, these four values are the same
// compile-time constants regardless of t, so there is nothing to cache).
type enforceTTLSet struct {
min, hour, day, month time.Duration
}
// enforceTTLsFor returns enforceTTLFor's four window floors at once, so
// checkAndCount/account/countTargetRequests/recordProviderAttempt can
// build every counterIncr in the call from one shared value instead of
// calling enforceTTLFor per entry.
func enforceTTLsFor() enforceTTLSet {
return enforceTTLSet{min: enforceTTLMinWindow, hour: enforceTTLHourWindow, day: enforceTTLDayWindow, month: enforceTTLMonthWindow}
}
// windowKeyForBucket builds the counterStore key for one (kind, id,
// metric, window) counter given an already-computed bucket string:
// llmgw:{kind}:{id}:{metric}:{window}:{bucket}. windowKey (below) is the
// bucketFor(t, window)-deriving convenience wrapper every existing caller
// still uses; checkAndCount/account/countTargetRequests/
// recordProviderAttempt call this directly with a bucket from their own
// bucketsFor(now) call instead, skipping a redundant bucketFor call per
// counterIncr when several entries in the same call already share the
// same window's bucket.
func windowKeyForBucket(kind, id, metric, window, bucket string) string {
return strings.Join([]string{"llmgw", kind, id, metric, window, bucket}, ":")
}
// windowKey builds the counterStore key for one (kind, id, metric, window)
// counter at time t: llmgw:{kind}:{id}:{metric}:{window}:{bucket}. bucket
// is t.UTC() formatted to the window's granularity (bucketFor), so a
// counter's key changes automatically when its window rolls over.
func windowKey(kind, id, metric, window string, t time.Time) string {
return windowKeyForBucket(kind, id, metric, window, bucketFor(t, window))
}
// windowEnd returns the UTC instant at which window's bucket containing t
// ends: the next hour or minute boundary, the next UTC midnight, or the
// first of next month UTC.
func windowEnd(t time.Time, window string) time.Time {
u := t.UTC()
switch window {
case windowHour:
return time.Date(u.Year(), u.Month(), u.Day(), u.Hour(), 0, 0, 0, time.UTC).Add(time.Hour)
case windowMin:
return time.Date(u.Year(), u.Month(), u.Day(), u.Hour(), u.Minute(), 0, 0, time.UTC).Add(time.Minute)
case windowDay:
return time.Date(u.Year(), u.Month(), u.Day(), 0, 0, 0, 0, time.UTC).AddDate(0, 0, 1)
case windowMonth:
return time.Date(u.Year(), u.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0)
default:
panic(fmt.Sprintf("llmgateway: windowEnd: unknown window %q", window))
}
}
// retryAfterSeconds returns the whole number of seconds from t until
// window's bucket ends, rounded up so a client never retries a moment too
// early.
func retryAfterSeconds(t time.Time, window string) int {
return int(math.Ceil(windowEnd(t, window).Sub(t.UTC()).Seconds()))
}
// checkAndCountKeysPerScope is the number of counterIncr entries
// checkAndCount builds per scope (req:min, req:day, req:hour), and the
// stride storeIncrAndGetMulti's flat incrVals result is sliced back into
// per-scope counts by.
const checkAndCountKeysPerScope = 3
// budgetProbe names one token/cost budget checkAndCount's fused admission
// round trip must evaluate — the same values the pre-fusion implementation
// checked via its own evaluateScope/budgetViolation/tokenBudgetViolation
// helpers (removed by perf review round 3, 2026-08-22), now inlined into
// checkAndCount itself so their GET reads can ride the same round trip as
// checkAndCount's own request-counter increments (see incrAndGetMulti,
// counterStore's own doc comment above, for why that fusion is safe).
type budgetProbe struct {
name string // e.g. "tokens-per-day" — echoed into the violation message verbatim
window string // windowDay or windowMonth; retryAfterSeconds is computed against this
scopeIdx int // index into the scopes slice checkAndCount was given
limit int64 // already micro-USD for a cost probe (usdToMicros); always > 0
keyStart int // index into buildBudgetProbes' own reads slice
tokens bool // true: keyStart/keyStart+1 are tokin/tokout, summed; false: keyStart alone is cost
}
// buildBudgetProbes returns, for every scope in scopes that has a
// TokensPerDay/TokensPerMonth/CostPerDayUSD/CostPerMonthUSD limit set
// (limit<=0 means unlimited and needs no read at all — the same
// "limit<=0 returns immediately, before touching the store" short-circuit
// the pre-fusion budgetViolation/tokenBudgetViolation applied), the probe
// describing that budget and the flat list of counterStore keys
// checkAndCount's fused round trip must read to evaluate it. Probes are
// appended in the SAME order the pre-fusion evaluateScope checked them,
// scope by scope: tokens-per-day, tokens-per-month, cost-per-day,
// cost-per-month within each scope, scopes themselves in their own given
// order — so a caller that walks probes in order after the round trip
// completes reproduces the identical violation precedence.
func buildBudgetProbes(scopes []limitScope, now time.Time) (probes []budgetProbe, reads []string) {
for i, sc := range scopes {
if sc.limits == nil {
continue
}
lim := sc.limits
add := func(name, window string, limit int64, tokens bool) {
if limit <= 0 {
return
}
probes = append(probes, budgetProbe{scopeIdx: i, name: name, window: window, limit: limit, tokens: tokens, keyStart: len(reads)})
if tokens {
reads = append(reads,
windowKey(sc.kind, sc.id, metricTokIn, window, now),
windowKey(sc.kind, sc.id, metricTokOut, window, now))
} else {
reads = append(reads, windowKey(sc.kind, sc.id, metricCost, window, now))
}
}
add("tokens-per-day", windowDay, lim.TokensPerDay, true)
add("tokens-per-month", windowMonth, lim.TokensPerMonth, true)
add("cost-per-day", windowDay, usdToMicros(lim.CostPerDayUSD), false)
add("cost-per-month", windowMonth, usdToMicros(lim.CostPerMonthUSD), false)
}
return probes, reads
}
// checkAndCount increments every scope's req:min, req:day, and req:hour
// counters — unconditionally, before any evaluation — then evaluates each
// scope's set limits, in order, and returns the first violation found, or
// nil if the request may proceed. If the round trip fails closed (a
// configured store errored and failOpen is false), it returns a
// storeDownViolation immediately — the request is refused rather than
// evaluated against partial or fallback-only counters.
//
// On a violation, settleRejection (finding F1, 2026-09 review; extended by
// F4, v0.3 dashboard task, to also record the fleet-wide rejection —
// see its own doc comment) compensates the increments made above for
// every scope OTHER than the one that actually violated: the violating
// scope's own counter still reflects a real admission attempt against
// ITS OWN limit — unchanged, intentional — but WITHOUT the rollback,
// every OTHER scope (a user's group, the synthetic total scope) would
// also count that same rejected request toward ITS rate tracking, even
// though it never had anything to do with the rejection. Left unrolled-
// back, a single user hammering past their own requests-per-minute limit
// drives their group's identical counter up on every one of those 429s
// too, and can lock out every OTHER member of the group once the group's
// own limit is reached from traffic that was never actually admitted.
//
// Every scope's three increments AND every scope's token/cost budget reads
// ride ONE storeIncrAndGetMulti round trip (perf review round 3,
// 2026-08-22, fusing on top of round 1's own "one round trip for the
// increments" fix): a 2-scope request (user, group — buildLimitScopes,
// routes_unified.go) with both scopes fully limited previously paid that
// one round trip for the increments, PLUS up to 4 more SEPARATE, SERIAL
// round trips per scope for its token/cost budgets (evaluateScope calling
// tokenBudgetViolation twice and budgetViolation twice) — 9 total before
// this fix. incrAndGetMulti's own reply already carries each entry's
// post-increment value AND each budget key's current value, in order, so
// evaluation below needs no further store call at all. This is safe
// because every budget key (tokin/tokout/cost:*) is disjoint from every
// increment's key (req:*), and tokens/cost are only ever written by
// account() AFTER the upstream response completes — never by this call —
// so reading them alongside the increments carries no read-after-write
// hazard; see incrAndGetMulti's own doc comment (counterStore, above) for
// the full argument.
//
// Violation precedence is unchanged from the pre-fusion implementation:
// scopes are evaluated in their own given order (buildLimitScopes puts a
// user before their group; withTotalScope appends the synthetic total
// scope last), and within one scope: requests-per-minute, requests-per-day,
// tokens-per-day, tokens-per-month, cost-per-day, cost-per-month — the
// first violation found anywhere in that walk is returned immediately, and
// evaluation of any later scope never runs. evaluateScope/budgetViolation/
// tokenBudgetViolation (the pre-fusion, separate-round-trip
// implementation) are removed by this change — buildBudgetProbes plus the
// loop below inline their exact logic instead, which is what lets their
// store reads ride this one round trip.
//
// Requests limits compare against the value just incremented in this call.
// Token and cost limits compare against the value already accumulated by
// account (via this call's own reads): a request that would start over
// budget is refused, but because usage is only known after the upstream
// call completes, one request may still push a scope over its budget — an
// accepted trade-off, unchanged from before this round.
func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation {
now := l.now()
wb := bucketsFor(now)
et := enforceTTLsFor()
entries := make([]counterIncr, 0, len(scopes)*checkAndCountKeysPerScope)
for _, sc := range scopes {
entries = append(entries,
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
)
}
// Last-seen (Q6, DECISIONS) no longer rides this batch (P9 fix, admin
// dashboard redesign verify round): it used to be appended here,
// unconditionally, which meant a REJECTED request still moved that
// scope's last-seen timestamp forward before checkAndCount even knew
// whether it was about to refuse the request. recordLastSeen, below,
// is now called by every admission call site's own "violation == nil"
// branch instead — see its own doc comment.
probes, reads := buildBudgetProbes(scopes, now)
incrVals, readVals, ok := l.storeIncrAndGetMulti(entries, reads)
if !ok || len(incrVals) != len(entries) || len(readVals) != len(reads) {
l.rejections.increment(rejectionScopeStoreDown, rejectionScopeStoreDown)
return storeDownViolation()
}
for i, sc := range scopes {
if sc.limits == nil {
continue
}
minCount := incrVals[i*checkAndCountKeysPerScope]
dayCount := incrVals[i*checkAndCountKeysPerScope+1]
// incrVals[i*checkAndCountKeysPerScope+2] is the req:hour count —
// stats-only, deliberately never read here.
if v := requestLimitViolation(sc, "requests-per-minute", sc.limits.RequestsPerMinute, minCount, windowMin, now); v != nil {
l.rejections.increment(sc.kind, sc.id)
l.settleRejection(entries, i, scopes, wb, et)
return v
}
if v := requestLimitViolation(sc, "requests-per-day", sc.limits.RequestsPerDay, dayCount, windowDay, now); v != nil {
l.rejections.increment(sc.kind, sc.id)
l.settleRejection(entries, i, scopes, wb, et)
return v
}
for _, p := range probes {
if p.scopeIdx != i {
continue
}
used := readVals[p.keyStart]
if p.tokens {
used += readVals[p.keyStart+1]
}
if used < p.limit {
continue
}
l.rejections.increment(sc.kind, sc.id)
l.settleRejection(entries, i, scopes, wb, et)
return &limitViolation{
message: fmt.Sprintf("%s %q exceeded %s budget", sc.kind, sc.id, p.name),
retryAfter: retryAfterSeconds(now, p.window),
kind: eventKindBudget,
}
}
}
return nil
}
// recordLastSeen writes the absolute last-seen SET entries lastSeenEntries
// (below) builds for scopes, fire-and-forget (countAsync/l.spawn) — P9 fix
// (admin dashboard redesign verify round): every admitRequestForRoute-
// shaped call site calls this ONLY from its own "violation == nil" branch,
// after checkAndCount has already reported admission, so a request
// checkAndCount refuses never touches last-seen at all. Moving it off
// checkAndCount's own synchronous batch adds no new round trip to the
// admitted path either — countAsync already spawns off the request's own
// goroutine, the same shape recordCacheHit/recordUnpriced402 use.
func (l *limiter) recordLastSeen(scopes []limitScope, now time.Time) {
l.countAsync(l.lastSeenEntries(scopes, now))
}
// lastSeenEntries returns the absolute last-seen SET entries recordLastSeen
// (above) spawns — one per "user" or "group" scope in scopes (never the
// synthetic total scope: a fleet-wide "when was anyone last seen" has no
// meaning), skipped entirely when admin stats are off (l.statsAdmin) or
// this replica already wrote one for that scope within lastSeenGateThrottle
// (l.lastSeen.due). TTL is monthWindowTTL
// (400d, matching plan §1.2's last-seen retention) with no enforceTTL
// floor (0): unlike a windowed counter, a last-seen value has no
// correctness-driven minimum lifetime — memoryStoreMaxTTL's own ceiling is
// free to shorten the in-process fallback's copy, since it is refreshed on
// every admitted request that scope makes anyway.
func (l *limiter) lastSeenEntries(scopes []limitScope, now time.Time) []counterIncr {
if !l.statsAdmin {
return nil
}
var out []counterIncr
for _, sc := range scopes {
if sc.kind != "user" && sc.kind != "group" {
continue
}
if !l.lastSeen.due(sc.kind, sc.id, now) {
continue
}
out = append(out, counterIncr{key: lastSeenKey(sc.kind, sc.id), delta: now.Unix(), ttl: monthWindowTTL, absolute: true})
}
return out
}
// settleRejection compensates the req:min/req:day/req:hour increments
// checkAndCount already made for every scope OTHER than violatedIdx —
// exactly what rollbackOtherScopeCounts (its pre-F4 name) did — AND, in
// the SAME batched store call, records the fleet-wide rejection: +1 to
// the violated scope's own rej:day counter, and +1 to the synthetic total
// scope's rej:day counter when one is present in scopes (F4, v0.3
// dashboard task — GET /admin/api/usage's rejectionsPerDay field, Q6:
// "attribute rejections to total/all too"). Folding this into the
// compensating batch, rather than a separate storeIncrMulti call, means a
// rejection still pays exactly the one further round trip it already did
// before F4 existed — see checkAndCount's own doc comment for the round-
// trip accounting this preserves.
func (l *limiter) settleRejection(entries []counterIncr, violatedIdx int, scopes []limitScope, wb windowBuckets, et enforceTTLSet) {
comp := make([]counterIncr, 0, len(scopes)*checkAndCountKeysPerScope+4)
totalPresent := false
for i, sc := range scopes {
if i == violatedIdx {
continue
}
for k := 0; k < checkAndCountKeysPerScope; k++ {
e := entries[i*checkAndCountKeysPerScope+k]
comp = append(comp, counterIncr{key: e.key, delta: -e.delta, ttl: e.ttl, enforceTTL: e.enforceTTL})
}
if sc.kind == totalScopeKind && sc.id == totalScopeID {
totalPresent = true
}
}
violated := scopes[violatedIdx]
comp = append(comp, rejectionCounterIncr(violated.kind, violated.id, windowDay, wb.day, et.day))
if totalPresent {
comp = append(comp, rejectionCounterIncr(totalScopeKind, totalScopeID, windowDay, wb.day, et.day))
}
// rej/hour (always-on-with-admin, DECISIONS): rides this SAME
// compensation batch, never a second round trip, alongside the
// existing always-on rej/day entries above.
if l.statsAdmin {
comp = append(comp, rejectionCounterIncr(violated.kind, violated.id, windowHour, wb.hour, et.hour))
if totalPresent {
comp = append(comp, rejectionCounterIncr(totalScopeKind, totalScopeID, windowHour, wb.hour, et.hour))
}
}
if _, ok := l.storeIncrMulti(comp); !ok {
l.logf("limits: rollback/rejection-accounting batch failed after a rejection; other scopes' req:min/day/hour counters may be over-counted by 1, and rejectionsPerDay may undercount, for the current window")
}
}
// rejectionCounterIncr builds the single rej counterIncr entry
// settleRejection adds per scope it attributes a rejection to, for window
// (windowDay, always-on; windowHour, admin-gated) — delta 1, ttl the
// window's own matching constant (dayWindowTTL/hourWindowTTL), enforceTTL
// the caller's own et.day/et.hour.
func rejectionCounterIncr(kind, id, window, bucket string, enforceTTL time.Duration) counterIncr {
ttl := dayWindowTTL
if window == windowHour {
ttl = hourWindowTTL
}
return counterIncr{key: windowKeyForBucket(kind, id, metricRej, window, bucket), delta: 1, ttl: ttl, enforceTTL: enforceTTL}
}
// requestLimitViolation reports a violation when count (already
// incremented for this request) exceeds limit. limit<=0 means unlimited.
func requestLimitViolation(sc limitScope, name string, limit, count int64, window string, now time.Time) *limitViolation {
if limit <= 0 || count <= limit {
return nil
}
return &limitViolation{
message: fmt.Sprintf("%s %q exceeded %s limit (%d)", sc.kind, sc.id, name, limit),
retryAfter: retryAfterSeconds(now, window),
kind: eventKindRateLimit,
}
}
// account records u's prompt/completion tokens — split into metricTokIn/
// metricTokOut (v0.2 data-layer task) — and costMicros against every
// scope's hour, day, and month counters. Each metric/direction is skipped
// independently when its own value is 0 — no store write for a direction
// this call has nothing to report, so a streaming response cut off before
// any completion tokens arrived still writes tokin alone.
//
// Every scope's writes are collected into ONE storeIncrMulti call (perf
// review, 2026-08-21): a 3-scope request (user, group, total) with all
// three metrics nonzero previously paid up to 9 round trips per scope —
// 27 total — one single-key store write each. account's return-value discipline
// is unchanged by the batching: it has no error return, and a sample that
// hits a fail-closed batch (see storeIncrMulti) is dropped in full,
// silently, rather than counting it in the fallback — dropping avoids
// double-counting once the store recovers, the same reasoning that
// applied per-key before this call became one batch.
// accountExtras carries the per-call, non-scope facts accountWith's own
// extra counter families need — never a limitScope, since none of these
// are things a request is admitted or budgeted against, only recorded
// alongside its usage. account (limits_helpers_test.go, admin-redesign
// WP-G cleanup: every production call site now calls accountWith
// directly, so account itself is test-only) is accountWith with a
// zero-value accountExtras, so every pre-existing caller's behavior is
// byte-for-byte unchanged.
type accountExtras struct {
// provider is the serving candidate's own provider name — accountWith
// writes its opt-in latency buckets (statsLatency) under this, and its
// failover-from entry (below) is keyed by the PREVIOUS candidate's
// provider name, not this one.
provider string
// failoverFrom is the previous candidate's provider name, set only
// when this account call is for a candidate reached after an earlier
// one failed (i > 0 in runMeteredCall's own loop) — accountWith
// increments prov/{failoverFrom}:fover when non-empty and admin stats
// are on.
failoverFrom string
// lat/hasLat carry one completed upstream watchdogBody's latency
// sample (metrics.go), when withLatencyRecorder was wired for this
// call and it actually fired — hasLat false means no sample arrived
// (metrics disabled and stats.latency off, or the watchdog never
// closed).
lat latencySample
hasLat bool
// cacheMiss reports whether THIS candidate's own cacheable response-
// cache lookup (routes_unified.go) was attempted and missed — always
// false for a request the cache was never consulted for. A HIT never
// reaches accountWith at all (routes_unified.go returns before ever
// calling it); recordCacheHit, below, is that path's own async
// counterpart.
cacheMiss bool
// noModelHistograms suppresses accountWith's own model-scope latency
// AND user x model (umodel) writes even when scopes DOES carry a
// kindModel scope (P13 fix, admin dashboard redesign verify round:
// plan §2(c) says passthrough gets "provider histograms only", but
// routes_passthrough.go always passed the served model's scope, so
// latency (28 metrics x hour/day) and umodel entries were ALSO
// written keyed by an upstream-echoed model id — bounded only by
// length/control-char, never by a configured catalog, and never read
// back anywhere: usage/models and usermodel totals both rank off the
// CONFIGURED catalog, not whatever a passthrough response happened to
// name). The base req/tokin/tokout/cost model-scope counters (the
// per-scope loop above, driven purely by which scopes the caller
// passed in) are unaffected — only the two opt-in, per-model-keyed
// families this flag names.
noModelHistograms bool
}
// accountWith is account generalized with accountExtras (admin-redesign
// WP-A): every scope's own req/tokin/tokout/cost entries below are
// byte-for-byte what account always built; x's own fields each add AT
// MOST a few more counterIncr entries to the SAME batch, never a second
// storeIncrMulti round trip — see accountExtras' own field docs and this
// function's per-block comments below for exactly what each one writes
// and when.
func (l *limiter) accountWith(scopes []limitScope, u usage, costMicros int64, x accountExtras) {
now := l.now()
wb := bucketsFor(now)
et := enforceTTLsFor()
// Security audit run-1: every figure reaching here is read verbatim
// out of an UPSTREAM response body (provider_openai.go's forwardJSON,
// the translate_*.go stream states, extractPassthroughUsage) into an
// int64, and is written below as an INCRBY DELTA. A negative value
// therefore DECREMENTS this principal's counters — and because
// checkAndCount admits while `used < limit`, a counter driven below
// zero stops every token and cost budget binding for the rest of that
// window. costMicros can also arrive negative from an int64 overflow
// in costMicros' own unguarded multiply (pricing.go) on an absurd
// reported token count.
//
// These counters are increment-only by contract, so a negative delta
// is never legitimate: drop it and say so, loudly enough that a
// misbehaving or hostile upstream is visible rather than silently
// rewriting a budget. Clamped per-component, so one bad field does not
// discard the other two, which may be perfectly valid.
if u.prompt < 0 || u.completion < 0 || costMicros < 0 {
l.logf("%s", fmt.Sprintf("limits: dropping negative usage reported by an upstream (prompt=%d completion=%d costMicros=%d); these counters are increment-only", u.prompt, u.completion, costMicros))
if u.prompt < 0 {
u.prompt = 0
}
if u.completion < 0 {
u.completion = 0
}
if costMicros < 0 {
costMicros = 0
}
}
entries := make([]counterIncr, 0, len(scopes)*12+16)
userScopeCount := 0
var userScopeID string
hasModelScope := false
var modelCanonical string
for _, sc := range scopes {
if sc.kind == "user" {
userScopeCount++
userScopeID = sc.id
}
if sc.kind == kindModel {
hasModelScope = true
modelCanonical = sc.id
}
// A kindModel scope is the one scope kind checkAndCount never
// sees: it is resolved post-response, because only then is the
// SERVING model known (failover can move a request to another
// provider after admission). So its request counter is written
// here rather than at admission, in this same batch — no extra
// round trip, and no double count, since admission never wrote
// one for this kind. Unconditional, unlike the three metrics
// below: a served request counts even when the upstream reports
// no usage at all.
if sc.kind == kindModel {
entries = append(entries,
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowMonth, wb.month), delta: 1, ttl: monthWindowTTL, enforceTTL: et.month},
)
}
if u.prompt != 0 {
entries = append(entries,
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricTokIn, windowHour, wb.hour), delta: u.prompt, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricTokIn, windowDay, wb.day), delta: u.prompt, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricTokIn, windowMonth, wb.month), delta: u.prompt, ttl: monthWindowTTL, enforceTTL: et.month},
)
}
if u.completion != 0 {
entries = append(entries,
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricTokOut, windowHour, wb.hour), delta: u.completion, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricTokOut, windowDay, wb.day), delta: u.completion, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricTokOut, windowMonth, wb.month), delta: u.completion, ttl: monthWindowTTL, enforceTTL: et.month},
)
}
if costMicros != 0 {
entries = append(entries,
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricCost, windowHour, wb.hour), delta: costMicros, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricCost, windowDay, wb.day), delta: costMicros, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricCost, windowMonth, wb.month), delta: costMicros, ttl: monthWindowTTL, enforceTTL: et.month},
)
}
}
// (a) user x model (opt-in, admin.stats.userModel): only when exactly
// ONE user scope is present (a multi-group principal's several group
// scopes never trigger this — there is still only one user) and a
// served model is known. <=8 more entries: req always (a served
// request counts even with zero usage, mirroring kindModel's own
// unconditional req write above), tokin/tokout/cost only when nonzero
// — day and month only, no hour (plan §1.2's own umodel row). Media's
// account(withModelScope(nil, ...), usage{}, 0) call has no user scope
// at all (scopes is nil there) and so never reaches this block.
if l.statsUserModel && userScopeCount == 1 && hasModelScope && !x.noModelHistograms {
id := userModelScopeID(userScopeID, modelCanonical)
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindUserModel, id, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(kindUserModel, id, metricReq, windowMonth, wb.month), delta: 1, ttl: monthWindowTTL, enforceTTL: et.month},
)
if u.prompt != 0 {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindUserModel, id, metricTokIn, windowDay, wb.day), delta: u.prompt, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(kindUserModel, id, metricTokIn, windowMonth, wb.month), delta: u.prompt, ttl: monthWindowTTL, enforceTTL: et.month},
)
}
if u.completion != 0 {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindUserModel, id, metricTokOut, windowDay, wb.day), delta: u.completion, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(kindUserModel, id, metricTokOut, windowMonth, wb.month), delta: u.completion, ttl: monthWindowTTL, enforceTTL: et.month},
)
}
if costMicros != 0 {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindUserModel, id, metricCost, windowDay, wb.day), delta: costMicros, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(kindUserModel, id, metricCost, windowMonth, wb.month), delta: costMicros, ttl: monthWindowTTL, enforceTTL: et.month},
)
}
}
// (c) failover (always-on-with-admin): +1 to the PREVIOUS candidate's
// own prov/{from}:fover hour+day counters, every time runMeteredCall's
// own loop moves to a next candidate after an earlier one failed.
if x.failoverFrom != "" && l.statsAdmin {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProvider, x.failoverFrom, metricFover, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(kindProvider, x.failoverFrom, metricFover, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
}
// (c) latency (opt-in, admin.stats.latency): the serving provider's
// own bucket, plus the served model's own bucket when one is known —
// each hour+day, duration always, TTFB only when the sample carries
// one (a body that never delivered a successful byte has nothing
// meaningful to report there).
if x.hasLat && l.statsLatency {
if x.provider != "" {
entries = append(entries, latencyCounterEntries(kindProvider, x.provider, x.lat, wb, et)...)
}
if hasModelScope && !x.noModelHistograms {
entries = append(entries, latencyCounterEntries(kindModel, modelCanonical, x.lat, wb, et)...)
}
}
// (d) cache miss (always-on-with-admin, cache already required to be
// enabled for cacheMiss to ever be true at all — routes_unified.go
// only sets it inside its own cacheable branch): total cmiss hour+day,
// plus the served model's own cmiss at day only (matching every other
// model-scope cache metric's day-only granularity — recordCacheHit's
// identical rule for chit/csave).
if x.cacheMiss && l.statsAdmin {
entries = append(entries,
counterIncr{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricCmiss, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricCmiss, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
if hasModelScope {
entries = append(entries, counterIncr{key: windowKeyForBucket(kindModel, modelCanonical, metricCmiss, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day})
}
}
if len(entries) == 0 {
return // every metric was zero for every scope, and no extra was set — nothing to write
}
l.storeIncrMulti(entries) // no error return by contract (doc comment above); ok is intentionally discarded
}
// latencyCounterEntries returns the hour+day duration-bucket entries for
// (kind, id) — plus the matching TTFB-bucket entries when lat.hasTTFB —
// accountWith's own opt-in latency block (above) appends for the serving
// provider and, separately, for the served model. latencyBucketIndex
// (metrics.go) already folds "past every configured bound" into its own
// returned index (len(latencyBucketBounds)), so no separate overflow
// branch is needed here.
func latencyCounterEntries(kind, id string, lat latencySample, wb windowBuckets, et enforceTTLSet) []counterIncr {
durIdx, _ := latencyBucketIndex(lat.duration)
durMetric := latencyDurationMetric(durIdx)
out := []counterIncr{
{key: windowKeyForBucket(kind, id, durMetric, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
{key: windowKeyForBucket(kind, id, durMetric, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
}
if lat.hasTTFB {
ttfbIdx, _ := latencyBucketIndex(lat.ttfb)
ttfbMetric := latencyTTFBMetric(ttfbIdx)
out = append(out,
counterIncr{key: windowKeyForBucket(kind, id, ttfbMetric, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(kind, id, ttfbMetric, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
}
return out
}
// recordCacheHit accounts one response-cache HIT (Q7, DECISIONS:
// "cache-hit body parse only when cache stats on: accept") — total
// chit/csave at hour and day, plus the served model's own chit/csave at
// day only (matching accountWith's own cmiss day-only rule for a model
// scope). Fire-and-forget (countAsync/l.spawn): a HIT has already written
// its full response to the client before routes_unified.go's own call
// site reaches this, so nothing here can ever delay it. csave entries are
// skipped when savedMicros is 0 (an unpriced or free model), mirroring
// account's own "skip a zero direction" rule.
func (l *limiter) recordCacheHit(canonical string, savedMicros int64) {
if !l.statsAdmin {
return
}
now := l.now()
wb := bucketsFor(now)
et := enforceTTLsFor()
entries := []counterIncr{
{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricChit, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricChit, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
}
if savedMicros != 0 {
entries = append(entries,
counterIncr{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricCsave, windowHour, wb.hour), delta: savedMicros, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricCsave, windowDay, wb.day), delta: savedMicros, ttl: dayWindowTTL, enforceTTL: et.day},
)
}
if canonical != "" {
entries = append(entries, counterIncr{key: windowKeyForBucket(kindModel, canonical, metricChit, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day})
if savedMicros != 0 {
entries = append(entries, counterIncr{key: windowKeyForBucket(kindModel, canonical, metricCsave, windowDay, wb.day), delta: savedMicros, ttl: dayWindowTTL, enforceTTL: et.day})
}
}
l.countAsync(entries)
}
// recordUnpriced402 accounts one "refused: unpriced model under a cost
// budget" event (Feature F-1, security audit run-1; events.go's
// recordUnpricedRefusalEvent calls this alongside its own gatewayEvent
// record, the one chokepoint both routes_unified.go's and routes_
// passthrough.go's identical 402 guard already share) — total r402
// hour+day, plus the refused model's own r402 hour+day when canonical is
// known. Fire-and-forget (countAsync/l.spawn): the request has already
// been refused with a clean 402 envelope by the time this runs.
func (l *limiter) recordUnpriced402(canonical string) {
if !l.statsAdmin {
return
}
now := l.now()
wb := bucketsFor(now)
et := enforceTTLsFor()
entries := []counterIncr{
{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricR402, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricR402, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
}
if canonical != "" {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindModel, canonical, metricR402, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(kindModel, canonical, metricR402, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
}
l.countAsync(entries)
}
// countTargetRequestsBy increments MULTIPLE target scopes' per-target
// request counters (Feature B, v0.21) — each at min, hour, day, AND
// month, the only scope kind this package tracks a month window of
// REQUESTS for — in ONE storeIncrMulti call. kind is targetScopeKind's
// own output ("mcp" or scopeKindAgent — mcp_a2a.go), id is the
// configured target name; both come embedded in windowKey's own key
// string, so a target scope can never collide with a user/group/total
// scope of the same id even by coincidence — kind is part of the key,
// not just a struct field. A scope's own `limits` field is ignored
// entirely (never read); callers pass plain {kind, id} pairs.
//
// This is deliberately NOT folded into checkAndCount: a target scope
// carries no limits by design (this round adds no config surface for
// MCP/agent limits), so there is nothing for checkAndCount to evaluate
// and no violation this call could ever produce — it is pure accounting, exactly
// like accountWith() itself, hence the identical "no error return, ok
// discarded" contract. Month exists here, uniquely, because the admin
// dashboard's targets endpoint (admin.go's adminTargetCountersView)
// surfaces a requestsPerMonth figure no user/group admin view does;
// growing checkAndCount's own req:min/day/hour batch to a month window for
// EVERY scope kind, just to serve this one endpoint's response shape, was
// rejected as scope creep touching user/group accounting nothing else in
// this round asked to change.
//
// Batching (review round 2, 2026-08-21): the federated /mcp endpoint's own
// tools/list fan-out can attempt several backend servers for ONE incoming
// client request — the earlier per-server countTargetRequest call inside
// each fan-out goroutine paid one round trip per server; this collects
// every scope from the whole fan-out into a single call after the fan-out's
// own wg.Wait(), the same "one request in, one round trip out" discipline
// checkAndCount/accountWith already apply per scope-slice.
//
// caller identity (target x caller, DECISIONS' "always-on-with-admin"
// family): when caller is non-empty and admin stats are on, ALSO appends
// kindTargetCaller req entries at day+month for every scope, in the SAME
// batch below already builds — never a second round trip. caller == ""
// (countTargetRequests/countTargetRequest, limits_helpers_test.go's
// test-only wrappers — every production call site now names a caller)
// skips this addition entirely.
//
// Callers: handleTargetProxy (mcp_a2a.go, a 1-element slice, once per
// proxied request, after its own user/group/total admission check passes)
// and the federated /mcp endpoint (mcp_federation.go: a 1-element slice for
// tools/call's single resolved target, or one element per server ATTEMPTED
// — not necessarily reached or succeeded — for tools/list's fan-out; see
// mcpFederatedToolsList's own doc comment for why "attempted" is the
// correct word here).
func (l *limiter) countTargetRequestsBy(caller string, scopes []limitScope) {
if len(scopes) == 0 {
return
}
now := l.now()
wb := bucketsFor(now)
et := enforceTTLsFor()
entries := make([]counterIncr, 0, len(scopes)*6)
for _, sc := range scopes {
entries = append(entries,
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(sc.kind, sc.id, metricReq, windowMonth, wb.month), delta: 1, ttl: monthWindowTTL, enforceTTL: et.month},
)
if caller != "" && l.statsAdmin {
id := targetCallerScopeID(sc.kind, sc.id, caller)
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindTargetCaller, id, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
counterIncr{key: windowKeyForBucket(kindTargetCaller, id, metricReq, windowMonth, wb.month), delta: 1, ttl: monthWindowTTL, enforceTTL: et.month},
)
}
}
l.storeIncrMulti(entries)
}
// countTargetRequestBy is countTargetRequest (limits_helpers_test.go's
// test-only wrapper) with a caller identity attached —
// countTargetRequestsBy's own single-target shape.
func (l *limiter) countTargetRequestBy(caller, kind, id string) {
l.countTargetRequestsBy(caller, []limitScope{{kind: kind, id: id}})
}
// targetCounterKeysPerScope is the number of windowKey strings
// targetUsageKeys builds per target scope, and the stride targetUsage's
// flat storeGetMulti result is sliced back into per-target chunks by.
const targetCounterKeysPerScope = 3
// targetUsageKeys returns the targetCounterKeysPerScope windowKey strings
// targetUsage reads for one (kind, id) target scope at time now — req at
// min, day, and month granularity, matching adminTargetCountersView's own
// field order (admin.go) exactly, so targetUsage can map storeGetMulti's
// result slice back by plain index. Hour is deliberately not read here:
// it is stats-only everywhere else in this package (windowHour's own doc
// comment) and no admin view — targets included — ever surfaces it.
func targetUsageKeys(kind, id string, now time.Time) []string {
return []string{
windowKey(kind, id, metricReq, windowMin, now),
windowKey(kind, id, metricReq, windowDay, now),
windowKey(kind, id, metricReq, windowMonth, now),
}
}
// targetCounters is one MCP-server's or agent's current-window request
// counters (Feature B, v0.21) — requests only, mirroring handleTargetProxy's
// own passthrough-only accounting (mcp_a2a.go): a target scope never
// accumulates tokens or cost, so there is nothing else to report.
type targetCounters struct {
requestsPerMinute int64
requestsPerDay int64
requestsPerMonth int64
}
// targetUsage reads every scope's current req:min/day/month counters in
// ONE storeGetMulti round trip, mirroring currentUsage's own single-batch
// discipline for the same reason: GET /admin/api/targets (admin.go) must
// not pay one round trip per configured MCP server and agent. Order is
// preserved: targetUsage(scopes)[i] corresponds to scopes[i]. Unlike
// scopeUsage, there is no per-entry storeDown flag — a fail-closed read
// (storeGetMulti's own contract) reports every target's counters as zero
// rather than partial/stale values, which is acceptable here specifically
// because a target scope carries no limit: nothing downstream ever treats
// this zero as "confirmed no traffic" the way scopeUsage.storeDown guards
// against for a limited scope.
//
// configuredStoreDown is checked in addition to storeGetMulti's own ok
// (finding F5, 2026-09 review, applying currentUsage/providerUsage's own
// fix here too): with the default failOpen=true, storeGetMulti's ok stays
// true during a store outage — it silently reads the in-process fallback
// instead, which never saw this target's real traffic — so ok alone
// cannot tell a real reading apart from a fail-open one served from an
// empty fallback. The output is identical either way (the same
// zero-value targetCounters this doc comment already describes for the
// fail-closed case), so this only changes what happens during a fail-open
// outage, matching the sibling reads' own behavior instead of silently
// serving fabricated zeros as though they were confirmed current usage.
func (l *limiter) targetUsage(scopes []limitScope) []targetCounters {
out := make([]targetCounters, len(scopes))
if len(scopes) == 0 {
return out
}
now := l.now()
allKeys := make([]string, 0, len(scopes)*targetCounterKeysPerScope)
for _, sc := range scopes {
allKeys = append(allKeys, targetUsageKeys(sc.kind, sc.id, now)...)
}
vals, ok := l.storeGetMulti(allKeys)
if !ok || len(vals) != len(allKeys) || l.configuredStoreDown() {
return out // zero-value counters; see doc comment above
}
for i := range scopes {
v := vals[i*targetCounterKeysPerScope : i*targetCounterKeysPerScope+targetCounterKeysPerScope]
out[i] = targetCounters{requestsPerMinute: v[0], requestsPerDay: v[1], requestsPerMonth: v[2]}
}
return out
}
// kindProvider and kindProviderModel are the limitScope.kind (and
// admin-API scope-kind) strings a provider's and a (provider, upstream
// model) pair's outcome counters use (Feature A, v0.22 — provider/model
// success-rate accounting) — collision-safety via kind embedding, mirroring
// scopeKindAgent's own rationale (mcp_a2a.go): windowKey embeds kind as the
// counter key's own leading segment, so a provider named identically to a
// user, group, or MCP/A2A target can never share a counter with it, and a
// provider-level scope can never collide with one of its own
// (provider, model) scopes either, even when a model happens to be named
// after its provider.
const (
kindProvider = "prov"
kindProviderModel = "provmodel"
)
// kindModel is the limitScope.kind (and admin-API scope-kind) string a
// SERVED model's usage counters use — requests, tokens and cost, the same
// four metrics a user or group scope accumulates, so the Charts view can
// render a model with the very same history endpoint and chart component.
//
// Distinct from kindProviderModel above, and deliberately so: that kind
// counts upstream ATTEMPTS and FAILURES (an availability signal, day
// window only), whereas this one counts delivered usage. A request that
// fails over from one provider to another increments attempt counters on
// both and usage counters only on the one that actually served it.
//
// Its id is the canonical "provider/model" (providerModelScopeID's own
// convention), never the bare model id: two providers can serve the same
// bare id at different prices, and the operator question this feature
// answers ("which model is costing me, and on whose backend") needs the
// serving provider kept in the key. windowKey embeds kind, so a model
// scope can never collide with a user, group, provider or target scope.
const kindModel = "model"
// maxModelScopeIDLen bounds the "provider/model" id withModelScope will
// accept as a counter-scope id. Generous next to any real model id —
// the longest in the built-in table is well under 40 bytes — and far
// below the 4MiB an upstream response body could otherwise put there
// (maxAccountingTeeBytes, routes_passthrough.go). Security audit run-1.
const maxModelScopeIDLen = 256
// withModelScope returns scopes plus a kindModel scope for canonical, the
// "provider/model" id that actually SERVED the request. It copies rather
// than appending in place: the caller's slice is the same one
// admitRequestForRoute already passed to checkAndCount, and growing it through a
// shared backing array would be a data race waiting to happen.
//
// canonical is returned unchanged (no model scope added) when it names no
// model — empty, or a bare "provider/" with nothing after the separator.
// That is the passthrough path's real case: extractPassthroughUsage can
// only report a model id when the upstream's own response body carries
// one, and attributing usage to a "provider/" bucket would invent a model
// that does not exist rather than admit the gap.
func withModelScope(scopes []limitScope, canonical string) []limitScope {
if canonical == "" || canonical[len(canonical)-1] == '/' {
return scopes
}
// Security audit run-1: on the passthrough route the model half of
// canonical is read VERBATIM out of the upstream's own response body
// (extractPassthroughUsage, routes_passthrough.go) with no length or
// character-set check, and every distinct id it yields mints its own
// set of counter keys — up to twelve, four of them retained for
// monthWindowTTL (400 days). An upstream (or, where a group sets no
// Models restriction, a caller whose requested id an OpenAI-compatible
// relay echoes back) could therefore grow the counter keyspace without
// bound. Bounding the id here is the one chokepoint every model-scope
// write passes through.
//
// An id that fails these checks drops the MODEL scope only: the
// request still accounts to user, group and total exactly as before,
// which is the same "attribute what we can, invent nothing" rule the
// empty/trailing-slash case above already follows.
if len(canonical) > maxModelScopeIDLen {
return scopes
}
for i := 0; i < len(canonical); i++ {
if canonical[i] < 0x20 || canonical[i] == 0x7f {
return scopes
}
}
out := make([]limitScope, len(scopes), len(scopes)+1)
copy(out, scopes)
return append(out, limitScope{kind: kindModel, id: canonical})
}
// scopesHaveCostBudget reports whether ANY scope in scopes configures a
// money budget — costPerDayUSD or costPerMonthUSD. A scope with nil
// limits (the synthetic total scope, and any user/group that configured
// none) cannot carry one.
//
// Security audit run-1, finding F-1 (high): this is the predicate that
// decides whether serving a model with NO known price would silently
// defeat a control the operator actually configured. It deliberately
// ignores token and request limits — those accrue correctly for an
// unpriced model and keep working; only the cost budget is the one that
// can never fire, because its counter is never written. Matching
// buildBudgetProbes' own `limit <= 0` rule, a zero or negative budget
// means unlimited and so does not count as configured.
func scopesHaveCostBudget(scopes []limitScope) bool {
for _, sc := range scopes {
if sc.limits == nil {
continue
}
if usdToMicros(sc.limits.CostPerDayUSD) > 0 || usdToMicros(sc.limits.CostPerMonthUSD) > 0 {
return true
}
}
return false
}
// metricProvAttempt and metricProvFail are the counter metric names
// recordProviderAttempt writes (Feature A, v0.22): every upstream attempt
// increments metricProvAttempt; only a provider-fault outcome
// (isTransient's own classification, retry.go — reused, not forked, per
// spec ruling) additionally increments metricProvFail.
const (
metricProvAttempt = "attempt"
metricProvFail = "fail"
)
// providerModelScopeID joins provider and model into kindProviderModel's
// id convention: "provider/model" — the same canonical id shape
// pricing.go's unifiedCostMicros and the admin API already use elsewhere
// for a (provider, model) pair, so a reader who already knows that
// convention needs no new one here.
//
// The "/" join is unambiguous even though model itself may legitimately
// contain "/" (a discovered id like "uni/deepseek-v4-flash-0731" —
// routableModelId's own doc comment, webui/src/lib/format.ts, documents
// the identical case for the webui's copy of this convention): provider
// is always validated against configNamePattern (providers.go) before it
// can reach here, and that pattern's character class excludes "/"
// entirely. Two distinct (provider, model) pairs can therefore never
// produce the identical joined string — a provider name can never itself
// contain the separator a model id might.
func providerModelScopeID(provider, model string) string {
return provider + "/" + model
}
// matchesSentinel reports whether err IS, or wraps (through any
// combination of single-%w and multi-%w Unwrap chains), sentinel —
// implemented with a real errors.Is call, NOT a hand-rolled Unwrap walk.
//
// This function used to hand-roll that walk instead, via two locally
// declared interfaces (single-error Unwrap() error, multi-error
// Unwrap() []error) matched with bare comma-ok type assertions — the
// documented-safe pattern providerHTTPError's own doc comment
// (providers.go) already established for a DIFFERENT direction: checking
// whether an INTERPRETED plugin-defined type (like providerHTTPError
// itself) satisfies the standard error interface, which is exactly what
// makes errors.As panic under Yaegi (it needs to synthesize interpreted
// type info for that check). That hand-rolled version was WRONG for
// THIS direction, and the SHOULD-A yaegi-check harness (v0.22 review
// round, round 2 — tools/yaegi-check/main.go's exerciseAttemptAccounting)
// caught it empirically: under the interpreter, `err.(multiUnwrapper)`
// — an INTERPRETED interface type (multiUnwrapper, declared in this
// file) asserted against a value whose concrete type is COMPILED
// (*fmt.wrapErrors, from a real fmt.Errorf call with more than one %w) —
// silently returned ok=false even though %T correctly reported
// *fmt.wrapErrors and that type genuinely implements Unwrap() []error in
// real, compiled Go. The instrumented follow-up (v0.22 final review)
// proved the rule is BROADER than the multi-%w shape: a comma-ok
// assertion of ANY compiled concrete value against ANY
// interpreter-declared interface type returns ok=false under Yaegi —
// a bare *url.Error asserted against a single-method
// interface{ Unwrap() error } declared in this file failed identically.
// Method count is irrelevant, the failure is silent (never a panic), and
// only the yaegi-check harness can catch it; compiled tests pass the
// broken code. This is the reverse direction of the already-documented
// errors.As trap, not previously known to this codebase.
//
// errors.Is does not have this problem here: sentinel (context.
// DeadlineExceeded, context.Canceled — isDeadlineExceeded's only
// callers) and every err this function is ever called with (the network/
// context error chain client.Do and fmt.Errorf produce) are BOTH always
// compiled-origin values — never an interpreted plugin type. errors.Is's
// own internal Unwrap-chain walk (including its Go 1.20+ multi-error
// support) therefore runs entirely within compiled code on compiled
// types, the same safe shape retry.go's isTransient already relies on
// for these identical two sentinels, proven correct under Yaegi across
// every prior review round. The risk this file's earlier version was
// trying to avoid — errors.As panicking on an INTERPRETED type — simply
// does not arise for a plain sentinel comparison against a compiled
// value. Verified directly: this replacement is what made SHOULD-A's
// timeout-driving harness pass under the real interpreter (round 2's
// hand-rolled fix, though it passed every compiled `go test`, did not).
//
// TRIP-WIRE, load-bearing and unenforced: the safety above depends on
// every error reaching this function being compiled-origin. If anyone
// ever wraps an upstream error in a PLUGIN-DECLARED error type before it
// gets here, errors.Is would have to call Unwrap() on a yaegi-proxied
// value from compiled code — the same reverse-direction dispatch that
// broke the hand-rolled walk — and would silently return false. Keep
// upstream error chains built exclusively from stdlib error types
// (fmt.Errorf, net/url, context), and keep yaegi-check's timeout probe
// in the gate: it is the only test that can see this class of failure.
func matchesSentinel(err, sentinel error) bool {
return errors.Is(err, sentinel)
}
// isDeadlineExceeded reports whether err IS, or wraps, context.
// DeadlineExceeded — SHOULD-1 ruling (v0.22 review round): retry.go's
// isTransient deliberately excludes context.DeadlineExceeded from what it
// retries (retrying after the gateway's own deadline already lapsed is
// pointless), and recordProviderAttempt's fail determination below reuses
// isTransient unforked for every other classification — but a deadline
// the GATEWAY set and the upstream never answered inside is very much a
// provider-health signal, arguably the clearest one isTransient's
// retry-specific exclusion was never meant to hide from accounting. A
// context.Canceled error (the client walked away) is deliberately NOT
// matched here — that is not the provider's fault, and stays an
// attempt-only outcome, exactly like isTransient already treats it.
func isDeadlineExceeded(err error) bool {
return matchesSentinel(err, context.DeadlineExceeded)
}
// recordProviderAttempt accounts ONE upstream HTTP attempt against
// provider (Feature A, v0.22), and against the (provider, model) pair too
// when model is non-empty — a caller that cannot cheaply know the upstream
// model before the attempt resolves (native passthrough, routes_
// passthrough.go's handlePassthrough: the model lives in the response
// body, read only after the attempt already happened) passes "" and gets
// provider-level accounting only.
//
// Every attempt increments an "attempts" counter; a provider-fault
// outcome additionally increments a "failures" counter. That outcome is
// exactly isTransient(resp, err)'s own classification (retry.go: a
// transport/connection error, HTTP 429, or a 5xx status) — reused, not
// forked, per spec ruling — WITH ONE NARROW ADDITION (SHOULD-1, v0.22
// review round): isDeadlineExceeded(err) also counts as a failure, even
// though isTransient itself returns false for it (see isDeadlineExceeded's
// own doc comment for why the two functions correctly disagree here). A
// context.Canceled error still counts as an attempt only, from both
// functions alike — the client walked away, not the provider's fault. A
// 4xx-but-not-429 response means the provider answered correctly to a
// request it did not like, which is not a provider-health signal, exactly
// the line isTransient already draws for "worth retrying".
//
// attempt-accounting: this is invoked once per upstream attempt, not once
// per logical request — attemptRecorderFromContext (providers.go) is
// looked up fresh inside retryPolicy.do's own retry loop and proxyUpstream's
// single client.Do call, so a chat completion retried twice before
// succeeding counts as two attempts here (one failure, one success), the
// same "count what was actually attempted" discipline
// countTargetRequests/-Request already apply to target scopes (mcp_a2a.go
// callers), consistent with those counters' own attempt/request semantics.
//
// The PROVIDER scope's counters are written at minute AND day
// granularity; the (provider, model) scope is written at DAY granularity
// only (finding, dead-code sweep, 2026-09 review: this file used to also
// write provmodel:*:attempt|fail:min every attempt, but providerCounterKeys
// below has never read a kindProviderModel scope's minute keys at all —
// SHOULD-2, v0.22 review round, its own doc comment — so those writes were
// 2-4 pure-cost INCRBY+EXPIRE commands per attempt with no reader anywhere
// in this codebase, admin.go, metrics.go, or webui/src; grepped again
// before removing them here). No month window for either scope: provider
// health is a now-and-today question (the Providers tab's success-rate
// badge, webui), not a billing one, so there is nothing here for a
// month-long retention window to serve.
//
// The store write itself is fire-and-forget (l.spawn — SHOULD-5, v0.22
// review round): entries is finished being built before spawn is called,
// and never touched again afterward, so capturing it in the closure below
// is race-free even though the write itself now runs on a goroutine this
// function does not wait for.
func (l *limiter) recordProviderAttempt(provider, model string, resp *http.Response, err error) {
if provider == "" {
return
}
now := l.now()
wb := bucketsFor(now)
et := enforceTTLsFor()
fail := isTransient(resp, err) || isDeadlineExceeded(err)
// timeout (always-on-with-admin, below): a strict SUBSET of fail — the
// gateway's own deadline lapsed, or the watchdog's stalled-progress
// sentinel fired — reusing matchesSentinel/isDeadlineExceeded exactly
// like recordUpstreamEvent's own identical classification (events.go),
// never a third, independently-drifting check.
timeout := matchesSentinel(err, errProviderTimeout) || isDeadlineExceeded(err)
entries := make([]counterIncr, 0, 14)
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvAttempt, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min},
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvAttempt, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
if fail {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvFail, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min},
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvFail, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
}
if model != "" {
// Day granularity only — see this function's own doc comment
// (dead-code sweep, 2026-09 review) for why a minute-window pair
// was removed here: providerCounterKeys never reads one for a
// kindProviderModel scope.
id := providerModelScopeID(provider, model)
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvAttempt, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
if fail {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvFail, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
}
}
// Always-on-with-admin (DECISIONS): hourly attempt/fail for prov and
// provmodel, plus prov's own timeout hour+day — zero cost when admin
// is disabled, riding this SAME async batch otherwise, never a second
// round trip.
if l.statsAdmin {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvAttempt, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
)
if fail {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvFail, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
)
}
if model != "" {
id := providerModelScopeID(provider, model)
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvAttempt, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
)
if fail {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProviderModel, id, metricProvFail, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
)
}
}
if timeout {
entries = append(entries,
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvTimeout, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
counterIncr{key: windowKeyForBucket(kindProvider, provider, metricProvTimeout, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
)
}
}
l.countAsync(entries)
}
// providerCounterKeysPerScope is the number of windowKey strings
// providerCounterKeys builds for a PROVIDER-level (kindProvider, or any
// kind other than kindProviderModel) scope — attempt/fail at minute, then
// attempt/fail at day: 4. A kindProviderModel scope reads half as many
// (providerModelCounterKeysPerScope, below) — SHOULD-2 ruling (v0.22
// review round): GET /admin/api/overview's own per-model minute counters
// fed nothing but a badge title, so a dashboard with N models paid N
// wasted minute-window reads on every 5s poll for data nobody displayed.
// providerCounterKeys and providerUsage's own slicing loop each branch on
// sc.kind == kindProviderModel directly rather than through a shared
// "key count for this kind" helper — with exactly two kinds and two
// branches, a third layer of indirection bought nothing a direct
// comparison did not already say more plainly, mirroring
// targetCounterKeysPerScope's fixed-stride convention exactly where the
// stride really IS fixed (every target scope there reads the same three
// keys) and diverging from it only where it now is not.
const (
providerCounterKeysPerScope = 4
providerModelCounterKeysPerScope = 2
)
// providerCounterKeys returns providerCounterKeysPerScope (or, for a
// kindProviderModel scope, providerModelCounterKeysPerScope) windowKey
// strings providerUsage reads for one (kind, id) scope at time now: a
// kindProviderModel scope gets attempt/fail at day only; every other kind
// (kindProvider) gets attempt/fail at minute, then attempt/fail at day —
// matching providerCounters' own field order exactly so providerUsage can
// map storeGetMulti's result slice back by plain index.
func providerCounterKeys(kind, id string, now time.Time) []string {
if kind == kindProviderModel {
return []string{
windowKey(kind, id, metricProvAttempt, windowDay, now),
windowKey(kind, id, metricProvFail, windowDay, now),
}
}
return []string{
windowKey(kind, id, metricProvAttempt, windowMin, now),
windowKey(kind, id, metricProvFail, windowMin, now),
windowKey(kind, id, metricProvAttempt, windowDay, now),
windowKey(kind, id, metricProvFail, windowDay, now),
}
}
// providerCounters is one provider's or (provider, model) pair's current-
// window attempt/failure counters (Feature A, v0.22) — mirrors
// targetCounters' own shape and read-only, admin-view-only role.
// attemptsMinute/failuresMinute stay at their zero value for a
// kindProviderModel scope (SHOULD-2, providerCounterKeys' own doc
// comment): providerUsage never reads a model scope's minute keys at
// all, so this is not a real "zero traffic this minute" reading for a
// model the way it is for a provider — a caller must not treat it as one.
type providerCounters struct {
attemptsMinute int64
failuresMinute int64
attemptsDay int64
failuresDay int64
// storeDown reports whether reading this scope's counters failed
// closed (a configured store errored and failOpen is false) —
// mirrors scopeUsage.storeDown exactly (review fix: the doc comment
// below used to claim "nothing downstream treats a provider's zero
// counters as confirmed no-traffic", which metrics.go's renderer
// made false the moment it existed — a Redis blip made every
// provider counter read as 0, then jump back to its real value on
// the next successful scrape, which Prometheus reads as a counter
// RESET followed by a fresh increase equal to the whole prior total:
// one transient store error paints a phantom multi-thousand-request
// spike. Every field is 0 when this is true; a caller must not
// present them as "confirmed zero usage", the identical rule
// scopeUsage's own doc comment states).
storeDown bool
}
// providerUsage reads every scope's current attempt/failure counters in
// ONE storeGetMulti round trip — mirrors targetUsage's own single-batch
// discipline (limits.go) for the identical reason: GET
// /admin/api/overview must not pay one round trip per configured provider
// and model. scopes is built by the caller (buildAdminOverview, admin.go)
// as kindProvider entries for the provider-level rows followed by
// kindProviderModel entries for the per-model breakdown, all in one
// slice, so both share this single round trip. Order is preserved:
// providerUsage(scopes)[i] corresponds to scopes[i]. A failed read
// (store down + fail-closed) returns every counter at its zero value
// WITH storeDown set (review fix: adversarial verification, 2026-08-23
// — the previous version of this comment claimed nothing downstream
// treats a provider's zero counters as confirmed no-traffic; metrics.go
// is exactly such a downstream, and without this flag a transient Redis
// blip fabricated a hard 0 that Prometheus reads as a counter reset,
// followed by a fresh "increase" equal to the whole prior total on the
// next successful scrape). A caller reading providerCounters must skip
// a storeDown entry the same way currentUsage's own callers already skip
// a storeDown scopeUsage — see that type's doc comment.
//
// Slicing is by running offset, not a fixed i*stride multiply (SHOULD-2,
// providerCounterKeysPerScope's own doc comment): a kindProvider and a
// kindProviderModel scope contribute a different number of keys to
// allKeys, so the flat result cannot be sliced back by a single constant
// stride the way targetUsage's fixed-shape scopes can.
func (l *limiter) providerUsage(scopes []limitScope) []providerCounters {
out := make([]providerCounters, len(scopes))
if len(scopes) == 0 {
return out
}
now := l.now()
allKeys := make([]string, 0, len(scopes)*providerCounterKeysPerScope)
offsets := make([]int, len(scopes))
for i, sc := range scopes {
offsets[i] = len(allKeys)
allKeys = append(allKeys, providerCounterKeys(sc.kind, sc.id, now)...)
}
vals, ok := l.storeGetMulti(allKeys)
// configuredStoreDown catches the fail-open case ok alone misses
// (review fix, adversarial verification 2026-08-23) — see
// currentUsage's identical check and its own doc comment for why.
if !ok || len(vals) != len(allKeys) || l.configuredStoreDown() {
for i := range out {
out[i] = providerCounters{storeDown: true}
}
return out
}
for i, sc := range scopes {
start := offsets[i]
if sc.kind == kindProviderModel {
out[i] = providerCounters{attemptsDay: vals[start], failuresDay: vals[start+1]}
continue
}
out[i] = providerCounters{
attemptsMinute: vals[start],
failuresMinute: vals[start+1],
attemptsDay: vals[start+2],
failuresDay: vals[start+3],
}
}
return out
}
// usageKeysPerScope is the number of windowKey strings usageWindowKeys
// builds per scope, and the stride currentUsage's flat storeGetMulti
// result is sliced back into per-scope chunks by. Named here, closing a
// v0.2 final review wave follow-up (2026-08-20) that flagged the previous
// literal "6" as a magic-number stride: this task grows the per-scope key
// count from 6 to 8 for the tokens-in/tokens-out split, which is exactly
// the moment a silent off-by-N here would have gone unnoticed. F4 (v0.3
// dashboard task) grows it again, 8 to 9, for the fleet-wide
// rejectionsPerDay counter (metricRej) usageWindowKeys now appends. Grows
// once more, 9 to 10, for the last-seen counter (Q6, DECISIONS) — see
// usageWindowKeys' own doc comment for the appended field's position.
const usageKeysPerScope = 10
// scopeUsage is one scope's (a user's, a group's, or the synthetic total
// scope's — totalScopeKind) current-window counter values — the admin
// dashboard's usage table (spec §4, v0.2) and GET /admin/api/usage's
// total row (v0.2 data-layer task). tokensInPerDay/tokensOutPerDay and
// their per-month counterparts replace a single combined
// tokensPerDay/tokensPerMonth pair (v0.2 data-layer task, tokens-in/
// tokens-out split): a caller wanting the combined total a
// LimitsConfig.TokensPerDay/TokensPerMonth limit is actually evaluated
// against (checkAndCount's own probe loop, via buildBudgetProbes) sums
// the two itself.
type scopeUsage struct {
kind string // "user", "group", or "total", mirroring limitScope.kind
id string
requestsPerMinute int64
requestsPerDay int64
tokensInPerDay int64
tokensInPerMonth int64
tokensOutPerDay int64
tokensOutPerMonth int64
costPerDayMicros int64
costPerMonthMicros int64
// rejectionsPerDay is the fleet-wide count of checkAndCount rejections
// attributed to this scope today (metricRej, F4 v0.3 dashboard task) —
// GET /admin/api/usage's rejectionsPerDay field. Unlike every other
// field here, this counts REFUSED requests, not admitted ones.
rejectionsPerDay int64
// lastSeen is the unix-second value THIS scope's last-seen SET last
// wrote (Q6, DECISIONS) — 0 when never seen, or when admin stats were
// off for the whole life of the deployment so far, matching GET
// /admin/api/usage's own "lastSeen 0 means never/unknown" contract.
lastSeen int64
// storeDown reports whether reading any of the usageKeysPerScope
// counters above failed closed (a configured store errored and
// failOpen is false) — mirrors limitViolation.storeDown. Every value
// field is 0 in that case; a caller must not present them as
// "confirmed zero usage".
storeDown bool
}
// usageWindowKeys returns the usageKeysPerScope windowKey strings
// currentUsage reads for sc at time now, in a fixed order — req/min,
// req/day, tokin/day, tokin/month, tokout/day, tokout/month, cost/day,
// cost/month, rej/day — matching scopeUsage's field order exactly, so
// currentUsage can map storeGetMulti's result slice back to named fields
// by plain index. rej/day (F4, v0.3 dashboard task) is appended after
// every original index; lastSeenKey (Q6, DECISIONS) is appended last of
// all — every existing index above it stays byte-for-byte unchanged. A
// last-seen key never rolls over (lastSeenKey has no window-bucket
// component), so it needs no `now` argument the way every other key here
// does; passed through only so this stays a single flat literal.
func usageWindowKeys(sc limitScope, now time.Time) []string {
return []string{
windowKey(sc.kind, sc.id, metricReq, windowMin, now),
windowKey(sc.kind, sc.id, metricReq, windowDay, now),
windowKey(sc.kind, sc.id, metricTokIn, windowDay, now),
windowKey(sc.kind, sc.id, metricTokIn, windowMonth, now),
windowKey(sc.kind, sc.id, metricTokOut, windowDay, now),
windowKey(sc.kind, sc.id, metricTokOut, windowMonth, now),
windowKey(sc.kind, sc.id, metricCost, windowDay, now),
windowKey(sc.kind, sc.id, metricCost, windowMonth, now),
windowKey(sc.kind, sc.id, metricRej, windowDay, now),
lastSeenKey(sc.kind, sc.id),
}
}
// currentUsage reads every scope's usageKeysPerScope current-window
// counters — the same (kind, id, metric, window) combinations
// checkAndCount/account already write — via the limiter's own
// storeGetMulti, so it applies the identical fail-open/fail-closed policy
// every enforcement read already does, and it is read-only: unlike
// checkAndCount, it never increments anything.
//
// storeDown is also set when configuredStoreDown reports the store
// currently latched (review fix, adversarial verification 2026-08-23):
// with failOpen true (the default), storeGetMulti's own ok stays true
// on a store outage — it silently reads the in-process fallback instead
// — so ok alone is not enough to tell a real reading apart from a
// fail-open one served from an empty fallback. Checked in addition to,
// never instead of, storeGetMulti's own ok/length checks below.
//
// ONE storeGetMulti call for the whole scopes slice (v0.2 final review
// wave, 2026-08-20; supersedes the "one call per scope" amendment this
// comment previously described, 2026-08-20 review): every scope's keys
// are flattened into a single round trip against the shared store,
// rather than N separate ones. The reviewer-confirmed reason for this
// second amendment: GET /admin/api/usage's per-scope loop was contending
// with request admission on the same store connection as live traffic,
// and N round trips (even pipelined usageKeysPerScope-at-a-time) scales
// with the number of configured users and groups in a way one round trip
// does not. buildAdminUsage (admin.go) drives this by concatenating its
// users, groups, and the total scope into one scopes slice before calling
// in, then slicing the flat result back into its response sections at the
// same split points.
//
// Tradeoff given up by this second amendment: a transient store error
// now marks every scope in the call storeDown together, where the
// previous per-scope loop could let one scope's failure land mid-batch
// while a later scope's own independent call still succeeded. That
// isolation was never a documented guarantee of this method, only an
// incidental property of how it was written — cutting N round trips to 1
// is the reviewer-confirmed right side of this tradeoff. Order is
// preserved: currentUsage(scopes)[i] corresponds to scopes[i].
func (l *limiter) currentUsage(scopes []limitScope) []scopeUsage {
out := make([]scopeUsage, len(scopes))
if len(scopes) == 0 {
return out
}
now := l.now()
allKeys := make([]string, 0, len(scopes)*usageKeysPerScope)
for _, sc := range scopes {
allKeys = append(allKeys, usageWindowKeys(sc, now)...)
}
vals, ok := l.storeGetMulti(allKeys)
// len(vals) != len(allKeys) is reachable only from a non-conforming
// counterStore implementation (a real respClient.getMulti and
// memoryStore.getMulti both always return one value per key) —
// guarded defensively so a future or test-only store's short slice
// reports every scope storeDown instead of panicking on an
// out-of-range index below (review sweep, 2026-08-20).
if !ok || len(vals) != len(allKeys) || l.configuredStoreDown() {
for i, sc := range scopes {
out[i] = scopeUsage{kind: sc.kind, id: sc.id, storeDown: true}
}
return out
}
for i, sc := range scopes {
v := vals[i*usageKeysPerScope : i*usageKeysPerScope+usageKeysPerScope]
out[i] = scopeUsage{
kind: sc.kind, id: sc.id,
requestsPerMinute: v[0], requestsPerDay: v[1],
tokensInPerDay: v[2], tokensInPerMonth: v[3],
tokensOutPerDay: v[4], tokensOutPerMonth: v[5],
costPerDayMicros: v[6], costPerMonthMicros: v[7],
rejectionsPerDay: v[8], lastSeen: v[9],
}
}
return out
}
// historyPoint is one bucket's counter value in a limiter.history result:
// bucket is the same string bucketFor produces for that instant (e.g.
// "2026082114" for an hour bucket), value is the counter's reading — 0
// for a bucket that was never written, matching get/getMulti's existing
// "0 for a missing key" contract.
type historyPoint struct {
bucket string
value int64
}
// modelSpanTotals/modelTotals/modelSpanTotalsMulti (limits_helpers_test.go,
// admin-redesign WP-G cleanup) read span counter buckets per id — via
// historyBucketKeys/historyBucketKeysAt, the identical per-id bucket walk
// history (below) uses for one scope — flattening every id's span keys
// into ONE storeGetMulti round trip. GET /admin/api/usage/models' actual
// production read path is admin.go's chunkedModelSpanTotals/
// chunkedModelSpanTotalsMulti, which read directly via
// historyBucketKeysAt+storeGetMulti in bounded-size chunks instead of
// delegating here (admin.go's own doc comment on chunkedModelSpanTotals
// explains why: a single limiter.modelSpanTotalsMulti call has no chunk
// boundary, so a wide model catalog could build one storeGetMulti larger
// than any real Redis pipeline should be). These three stay as test-only
// helpers because limits_test.go's own coverage of historyBucketKeys/
// historyBucketKeysAt's span and offset semantics is written against
// them.
// historyStepBack returns the instant window's bucket was current i steps
// before now: i=0 is now's own (current, possibly partial) bucket, i=1
// the previous one, and so on. hour and day step by a fixed duration/day
// count from now itself; month anchors on the 1st of now's own month
// first, then steps whole calendar months from there —
// time.Time.AddDate normalizes an overflowing day-of-month (e.g. "Mar 31"
// minus one month becomes "Mar 3", not "Feb 28/29"), so stepping from day
// 1 (which exists in every month) is the only way whole-month arithmetic
// stays exact regardless of what day of the month now is.
func historyStepBack(now time.Time, window string, i int) time.Time {
u := now.UTC()
switch window {
case windowHour:
return u.Add(-time.Duration(i) * time.Hour)
case windowDay:
return u.AddDate(0, 0, -i)
case windowMonth:
anchor := time.Date(u.Year(), u.Month(), 1, 0, 0, 0, 0, time.UTC)
return anchor.AddDate(0, -i, 0)
default:
panic(fmt.Sprintf("llmgateway: historyStepBack: unknown window %q", window))
}
}
// historyBucketKeysAt is historyBucketKeys generalized with an offset
// (WP-B's own comparison-mode reads: GET /admin/api/usage/models and
// /usage/series' ?offset= parameter): the returned span ends offset window
// units before now instead of at now itself, letting a caller read an
// EARLIER span of the identical width for a period-over-period
// comparison, without duplicating historyStepBack's own calendar-aware
// month arithmetic a second time. offset=0 is byte-for-byte
// historyBucketKeys' own pre-existing behavior — composing historyStepBack
// twice (once for offset, once per bucket within the span) is
// associative with calling it once for their sum, since historyStepBack's
// own month case always anchors on the 1st of whatever month it is
// applied to, and its result here is always itself a 1st-of-month date.
func historyBucketKeysAt(kind, id, metric, window string, now time.Time, span, offset int) (keys, buckets []string) {
base := now
if offset > 0 {
base = historyStepBack(now, window, offset)
}
keys = make([]string, span)
buckets = make([]string, span)
for i := 0; i < span; i++ {
t := historyStepBack(base, window, span-1-i)
keys[i] = windowKey(kind, id, metric, window, t)
buckets[i] = bucketFor(t, window)
}
return keys, buckets
}
// historyBucketKeys returns the span counterStore keys and their matching
// bucket label strings for (kind, id, metric, window), stepping back from
// now one whole window unit at a time — oldest first, the current
// (possibly partial) bucket last, matching GET /admin/api/usage/history's
// "inclusive of current bucket, oldest-first" contract. A thin offset=0
// wrapper around historyBucketKeysAt, above — every existing caller of
// this name keeps calling it, and its own behavior is unchanged.
func historyBucketKeys(kind, id, metric, window string, now time.Time, span int) (keys, buckets []string) {
return historyBucketKeysAt(kind, id, metric, window, now, span, 0)
}
// chunkedGetMulti reads keys via one or more storeGetMulti round trips,
// each capped at chunkSize keys, and returns the values concatenated back
// in the SAME order as keys (P6 fix, admin dashboard redesign verify
// round: GET /admin/api/usage/series and spanTotalsMulti's own predecessors
// each used to build ONE unbounded storeGetMulti pipeline regardless of
// how many keys the request's own scope/span/offset/metric combination
// worked out to — holding the shared Redis connection mutex, respClient
// (resp.go), for however long that single oversized pipeline took, while
// live traffic's own checkAndCount/account calls queued behind it).
// Chunking a flat key list this way is safe for a caller that does not
// need chunk boundaries aligned to any larger unit (e.g. one id's own
// span): values come back positionally identical to an unchunked read,
// this just bounds how many keys any ONE round trip carries. Any chunk
// that fails (storeDown or a mismatched read) fails the whole call — a
// series silently missing an arbitrary subset of its own buckets is worse
// than a 503, the same "no partial answer" rule every admin stats reader
// in this package already follows.
func (l *limiter) chunkedGetMulti(keys []string, chunkSize int) ([]int64, bool) {
if chunkSize < 1 {
chunkSize = len(keys)
}
out := make([]int64, 0, len(keys))
for len(keys) > 0 {
n := chunkSize
if n > len(keys) {
n = len(keys)
}
vals, ok := l.storeGetMulti(keys[:n])
if !ok || len(vals) != n || l.configuredStoreDown() {
return nil, false
}
out = append(out, vals...)
keys = keys[n:]
}
return out, true
}
// spanTotalsMulti is the ONE production "per id, per metric, sum over a
// span of window buckets" reader every admin stats endpoint that needs a
// span TOTAL (as opposed to GET /admin/api/usage/series' own per-bucket
// values, which read via chunkedGetMulti directly instead) now shares
// (P12 fix, admin dashboard redesign verify round: this exact shape used
// to exist three times over — stats_read.go's own readSpanTotals, admin.go's
// chunkedModelSpanTotals/chunkedModelSpanTotalsMulti hardcoded to
// kind=kindModel, and this file's own modelSpanTotalsMulti, which drifted
// to a test-only helper (limits_helpers_test.go) once WP-B's rewrite
// stopped calling it from production, so limits_test.go's own coverage of
// the offset/chunking semantics no longer exercised what actually ran in
// production). ids may name any kind's scopes; kind is fixed per call the
// same way historyBucketKeysAt itself takes one kind per call.
//
// Reads are chunked at usageModelsChunkKeys (admin.go) keys per
// storeGetMulti round trip, sized so each chunk's own key count
// (len(chunk)*len(metrics)*span) stays at or under that bound — mirroring
// chunkedModelSpanTotalsMulti's own pre-existing chunking math, generalized
// past kindModel. now is resolved once by the caller and threaded through
// every chunk, so every id's span ends at the identical instant regardless
// of how many chunks ids splits into. Any chunk that fails fails the whole
// call, for the identical "no partial ranking" reason chunkedGetMulti's own
// doc comment gives.
//
// An empty ids or metrics slice (or span < 1) returns immediately with
// each metric's own nil slice and ok=true — no store read at all, matching
// every span-total reader's pre-existing "empty input never touches the
// store" contract (TestModelSpanTotals_EmptyIDs_NoStoreRead pins this
// against an always-erroring store).
func (l *limiter) spanTotalsMulti(kind string, ids, metrics []string, window string, now time.Time, span, offset int) ([][]int64, bool) {
out := make([][]int64, len(metrics))
if len(ids) == 0 || len(metrics) == 0 || span < 1 {
return out, true
}
perChunk := usageModelsChunkKeys / (span * len(metrics))
if perChunk < 1 {
perChunk = 1
}
for m := range out {
out[m] = make([]int64, 0, len(ids))
}
remaining := ids
for len(remaining) > 0 {
n := perChunk
if n > len(remaining) {
n = len(remaining)
}
chunk := remaining[:n]
allKeys := make([]string, 0, len(chunk)*len(metrics)*span)
for _, id := range chunk {
for _, metric := range metrics {
keys, _ := historyBucketKeysAt(kind, id, metric, window, now, span, offset)
allKeys = append(allKeys, keys...)
}
}
vals, ok := l.chunkedGetMulti(allKeys, usageModelsChunkKeys)
if !ok || len(vals) != len(allKeys) {
return nil, false
}
stride := len(metrics) * span
for i := range chunk {
base := i * stride
for m := range metrics {
mBase := base + m*span
var sum int64
for _, v := range vals[mBase : mBase+span] {
sum += v
}
out[m] = append(out[m], sum)
}
}
remaining = remaining[n:]
}
return out, true
}
// history returns span counter values for (kind, id, metric, window)
// ending at now, oldest-first — GET /admin/api/usage/history's data
// source (v0.2 data-layer task). It computes every bucket key up front
// and reads them in ONE storeGetMulti round trip, the same single-batch
// discipline currentUsage already applies to a scope's current-window
// keys, scaled here to a whole span of one metric/window instead of a
// fixed set of usageKeysPerScope. ok is false only in the fail-closed
// case (storeGetMulti's own contract) OR when configuredStoreDown reports
// a fail-open outage in progress (finding F5, 2026-09 review, applying
// currentUsage/providerUsage's own fix here too — with the default
// failOpen=true, storeGetMulti's ok alone stays true while silently
// reading the in-process fallback, which a Redis blip empties out to
// near-zero, painting a false drop on the usage-history charts instead of
// the 503 this doc comment already promises for a store failure): a
// caller must not present the returned points as real data then.
func (l *limiter) history(kind, id, metric, window string, now time.Time, span int) ([]historyPoint, bool) {
keys, buckets := historyBucketKeys(kind, id, metric, window, now, span)
vals, ok := l.storeGetMulti(keys)
if !ok || len(vals) != len(keys) || l.configuredStoreDown() {
return nil, false
}
points := make([]historyPoint, span)
for i := range points {
points[i] = historyPoint{bucket: buckets[i], value: vals[i]}
}
return points, true
}