mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
feat(admin): add fleet statistics and admin read endpoints
Counters (always on when admin is enabled): - last seen per user and group, written asynchronously after admission, throttled to one write per scope per minute per replica - hourly provider and model attempts, failures and timeouts - failovers per provider, rejections per hour, and unpriced (402) refusals - cache hits, misses and cost saved - request counts per target and caller Opt-in counters under admin.stats (both default off): - userModel: usage per user and model - latency: latency histograms per provider and model All counters ride the existing Redis batches. A test measures three round trips per request with defaults and with every statistic on. A cache hit takes two. A model request is no longer counted for failed failover candidates. New admin endpoints: - usage/series and usage/totals - performance, with percentiles from the histograms - catalog, with the price billing actually uses and its source - consumers, with key source, grants and model access - config, with secrets redacted The overview gains features and a pricing summary. Usage gains lastSeen. usage/models gains offset and more detail fields. Reads are chunked at 1600 keys per round trip and capped at 64000 keys per request. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
This commit is contained in:
+815
@@ -2792,3 +2792,818 @@ func TestTargetUsage_ConfiguredStoreDown_FailOpen_ReturnsZeroCounters(t *testing
|
||||
t.Errorf("got[0] = %+v, want the zero value", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// --- admin-redesign WP-A (steps 1-8) ---
|
||||
|
||||
// TestMemoryStore_ApplySet_OverwritesRatherThanAdds proves memoryStore's
|
||||
// absolute-entry path (last-seen SET, Q6 DECISIONS) always REPLACES a
|
||||
// key's value, never adds to it — the SET-vs-INCRBY distinction
|
||||
// counterIncr.absolute exists to carry.
|
||||
func TestMemoryStore_ApplySet_OverwritesRatherThanAdds(t *testing.T) {
|
||||
m := newMemoryStore()
|
||||
m.nowFn = func() time.Time { return time.Unix(1000, 0) }
|
||||
|
||||
if got := m.applySet("k", 42, time.Minute); got != 42 {
|
||||
t.Fatalf("first applySet = %d, want 42", got)
|
||||
}
|
||||
if got := m.applySet("k", 7, time.Minute); got != 7 {
|
||||
t.Errorf("second applySet = %d, want 7 (overwrite, not 49)", got)
|
||||
}
|
||||
if v, err := m.get("k"); err != nil || v != 7 {
|
||||
t.Errorf("get(k) = %d, err=%v, want 7, nil", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryStore_IncrMulti_AbsoluteEntry_DispatchesToApplySet proves
|
||||
// incrMulti routes an absolute counterIncr through applySet (overwrite),
|
||||
// never applyIncr (add) — a non-absolute entry in the same batch still
|
||||
// increments normally.
|
||||
func TestMemoryStore_IncrMulti_AbsoluteEntry_DispatchesToApplySet(t *testing.T) {
|
||||
m := newMemoryStore()
|
||||
m.nowFn = func() time.Time { return time.Unix(2000, 0) }
|
||||
|
||||
out, err := m.incrMulti([]counterIncr{
|
||||
{key: "seen", delta: 555, ttl: time.Hour, absolute: true},
|
||||
{key: "count", delta: 3, ttl: time.Hour},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("incrMulti: %v", err)
|
||||
}
|
||||
if out[0] != 555 {
|
||||
t.Errorf("absolute entry result = %d, want 555", out[0])
|
||||
}
|
||||
if out[1] != 3 {
|
||||
t.Errorf("incr entry result = %d, want 3", out[1])
|
||||
}
|
||||
|
||||
out2, _ := m.incrMulti([]counterIncr{{key: "count", delta: 3, ttl: time.Hour}})
|
||||
if out2[0] != 6 {
|
||||
t.Errorf("second incr on non-absolute key = %d, want 6 (added)", out2[0])
|
||||
}
|
||||
out3, _ := m.incrMulti([]counterIncr{{key: "seen", delta: 999, ttl: time.Hour, absolute: true}})
|
||||
if out3[0] != 999 {
|
||||
t.Errorf("second absolute write = %d, want 999 (overwrite, not 555+999)", out3[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestLastSeenGate_ThrottlesWithin60Seconds is Q6's own throttle contract:
|
||||
// two due() calls for the same scope inside lastSeenGateThrottle yield
|
||||
// exactly one true; a different scope is never throttled by another
|
||||
// scope's own state.
|
||||
func TestLastSeenGate_ThrottlesWithin60Seconds(t *testing.T) {
|
||||
g := &lastSeenGate{}
|
||||
t0 := time.Unix(10_000, 0)
|
||||
|
||||
if !g.due("user", "alice", t0) {
|
||||
t.Fatal("first due() = false, want true")
|
||||
}
|
||||
if g.due("user", "alice", t0.Add(30*time.Second)) {
|
||||
t.Error("due() 30s later = true, want false (within throttle)")
|
||||
}
|
||||
if !g.due("user", "alice", t0.Add(61*time.Second)) {
|
||||
t.Error("due() 61s later = false, want true (throttle elapsed)")
|
||||
}
|
||||
if !g.due("user", "bob", t0.Add(30*time.Second)) {
|
||||
t.Error("due() for a different scope = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLastSeenGate_ThrottlesPastMapCap is P8 (admin dashboard redesign
|
||||
// verify round): due()'s two-map rotation wrote a scope forward into
|
||||
// `previous` once `current` filled past lastSeenGateMapCapBase, but
|
||||
// nothing ever CONSULTED previous on lookup — every scope that had been
|
||||
// rotated out was treated as brand new on its very next call, so
|
||||
// throttling silently stopped working once more than
|
||||
// lastSeenGateMapCapBase (4,096) distinct scopes were active. 5,000
|
||||
// distinct scopes, each looked up twice within the same second (round 2
|
||||
// repeats round 1's exact scopes, same instant), must produce exactly
|
||||
// 5,000 due()=true calls total — the pre-fix code produced far more,
|
||||
// since round 2 mostly missed both the (rotated) previous map's own
|
||||
// throttle and its freshly-empty current map. 5,000 stays below the N5
|
||||
// adaptive-growth threshold (it only crosses ONE rotation, and growth
|
||||
// only triggers on a SECOND rotation within lastSeenGateThrottle — see
|
||||
// TestLastSeenGate_AdaptiveCapacityHoldsPastEightThousandScopes for the
|
||||
// scope count that exercises growth itself), so this keeps proving the
|
||||
// original fixed-cap rotation path unchanged.
|
||||
func TestLastSeenGate_ThrottlesPastMapCap(t *testing.T) {
|
||||
g := &lastSeenGate{}
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
const scopeCount = 5000
|
||||
if scopeCount <= lastSeenGateMapCapBase {
|
||||
t.Fatalf("scopeCount (%d) must exceed lastSeenGateMapCapBase (%d) to exercise rotation", scopeCount, lastSeenGateMapCapBase)
|
||||
}
|
||||
writes := 0
|
||||
for round := 0; round < 2; round++ {
|
||||
for i := 0; i < scopeCount; i++ {
|
||||
if g.due("user", fmt.Sprintf("u%d", i), now) {
|
||||
writes++
|
||||
}
|
||||
}
|
||||
}
|
||||
if writes != scopeCount {
|
||||
t.Errorf("due()=true across 2 rounds of %d scopes within the same second = %d, want %d (round 2 must be fully throttled)", scopeCount, writes, scopeCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLastSeenGate_AdaptiveCapacityHoldsPastEightThousandScopes is N5
|
||||
// (verify-redesign-final.md): a fixed 4,096-entry cap only throttled
|
||||
// correctly up to ~2x itself (current+previous both full, ~8,192 live
|
||||
// scopes) — beyond that, current rotated out before lastSeenGateThrottle
|
||||
// could elapse for most of what it held, discarding still-fresh writes
|
||||
// on every rotation, which degraded round 2 of a repeat to one write per
|
||||
// admission instead of zero. 20,000 distinct scopes (well past the old
|
||||
// ~8,192 ceiling), each looked up twice within the same second, must
|
||||
// still produce close to 20,000 due()=true calls total (round 1 only;
|
||||
// round 2 must be (almost) fully throttled) — proving due()'s adaptive
|
||||
// capacity (grows on a same-window second rotation, capped at
|
||||
// lastSeenGateMapCapMax) keeps the whole live population inside
|
||||
// current+previous instead of thrashing it.
|
||||
func TestLastSeenGate_AdaptiveCapacityHoldsPastEightThousandScopes(t *testing.T) {
|
||||
g := &lastSeenGate{}
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
const scopeCount = 20000
|
||||
if scopeCount <= 2*lastSeenGateMapCapBase {
|
||||
t.Fatalf("scopeCount (%d) must exceed the old 2x-cap ceiling (%d) to exercise adaptive growth", scopeCount, 2*lastSeenGateMapCapBase)
|
||||
}
|
||||
writes := 0
|
||||
for round := 0; round < 2; round++ {
|
||||
for i := 0; i < scopeCount; i++ {
|
||||
if g.due("user", fmt.Sprintf("u%d", i), now) {
|
||||
writes++
|
||||
}
|
||||
}
|
||||
}
|
||||
// "~20000": round 1 always writes scopeCount times (every scope is
|
||||
// new). Round 2 may add a handful more only if a scope's write fell
|
||||
// right before the growth threshold was reached, never a large
|
||||
// fraction of scopeCount — the old, pre-fix behaviour wrote roughly
|
||||
// scopeCount MORE on round 2 alone (P8's "far more" regression, at
|
||||
// this population size), so a generous 1% slack still tells the two
|
||||
// behaviours apart with room to spare.
|
||||
maxWant := scopeCount + scopeCount/100
|
||||
if writes < scopeCount || writes > maxWant {
|
||||
t.Errorf("due()=true across 2 rounds of %d scopes within the same second = %d, want in [%d, %d]", scopeCount, writes, scopeCount, maxWant)
|
||||
}
|
||||
if g.capacity > lastSeenGateMapCapMax {
|
||||
t.Errorf("g.capacity = %d, want <= lastSeenGateMapCapMax (%d)", g.capacity, lastSeenGateMapCapMax)
|
||||
}
|
||||
total := len(g.current) + len(g.previous)
|
||||
if bound := 2 * lastSeenGateMapCapMax; total > bound {
|
||||
t.Errorf("len(current)+len(previous) = %d, want <= 2*lastSeenGateMapCapMax (%d) — memory bound violated", total, bound)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAndCount_NeverWritesLastSeen is the P9-fix replacement for this
|
||||
// test's own pre-fix name (AppendedAfterScopeStride): last-seen no longer
|
||||
// rides checkAndCount's batch AT ALL (admin dashboard redesign verify
|
||||
// round) — it used to be appended after every scope's own three stride
|
||||
// entries unconditionally, which meant a REJECTED request still moved a
|
||||
// scope's last-seen timestamp forward, since checkAndCount's whole batch,
|
||||
// last-seen tail included, was already sent to the store before the
|
||||
// violation check even ran. checkAndCount's own entry count is now
|
||||
// EXACTLY 3*checkAndCountKeysPerScope regardless of l.statsAdmin — see
|
||||
// TestRecordLastSeen_WritesTailEntriesForUserAndGroupScopesOnly, below,
|
||||
// for the tail-entry coverage this test used to carry, now exercised
|
||||
// against recordLastSeen directly (the function real admission call sites
|
||||
// invoke instead, only once admission is confirmed).
|
||||
func TestCheckAndCount_NeverWritesLastSeen(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
now := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
|
||||
l.nowFn = func() time.Time { return now }
|
||||
l.statsAdmin = true
|
||||
|
||||
scopes := []limitScope{
|
||||
{kind: "user", id: "alice", limits: &LimitsConfig{RequestsPerMinute: 1000}},
|
||||
{kind: "group", id: "friends", limits: &LimitsConfig{RequestsPerMinute: 1000}},
|
||||
{kind: totalScopeKind, id: totalScopeID},
|
||||
}
|
||||
if v := l.checkAndCount(scopes); v != nil {
|
||||
t.Fatalf("checkAndCount violation = %+v, want nil", v)
|
||||
}
|
||||
|
||||
if len(store.entries) != 3*checkAndCountKeysPerScope {
|
||||
t.Fatalf("len(store.entries) = %d, want %d (no last-seen tail)", len(store.entries), 3*checkAndCountKeysPerScope)
|
||||
}
|
||||
for i, e := range store.entries {
|
||||
if e.absolute {
|
||||
t.Errorf("entries[%d] (%q) is absolute, want checkAndCount to never write an absolute entry any more", i, e.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordLastSeen_WritesTailEntriesForUserAndGroupScopesOnly proves
|
||||
// recordLastSeen (limits.go; called by every admission call site's own
|
||||
// "violation == nil" branch, P9 fix) writes exactly one absolute SET per
|
||||
// "user"/"group" scope, keyed by lastSeenKey, and never one for the
|
||||
// synthetic total scope.
|
||||
func TestRecordLastSeen_WritesTailEntriesForUserAndGroupScopesOnly(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
now := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
|
||||
l.nowFn = func() time.Time { return now }
|
||||
l.statsAdmin = true
|
||||
l.spawn = func(f func()) { f() } // synchronous for the test to observe entries
|
||||
|
||||
scopes := []limitScope{
|
||||
{kind: "user", id: "alice"},
|
||||
{kind: "group", id: "friends"},
|
||||
{kind: totalScopeKind, id: totalScopeID},
|
||||
}
|
||||
l.recordLastSeen(scopes, now)
|
||||
|
||||
if len(store.entries) != 2 {
|
||||
t.Fatalf("len(store.entries) = %d, want 2 (user + group, never total)", len(store.entries))
|
||||
}
|
||||
seenKeys := map[string]bool{}
|
||||
for _, e := range store.entries {
|
||||
if !e.absolute {
|
||||
t.Errorf("entry %q is not absolute", e.key)
|
||||
}
|
||||
seenKeys[e.key] = true
|
||||
}
|
||||
if !seenKeys[lastSeenKey("user", "alice")] || !seenKeys[lastSeenKey("group", "friends")] {
|
||||
t.Errorf("entries = %v, want lastSeenKey for user alice and group friends", store.entries)
|
||||
}
|
||||
if seenKeys[lastSeenKey(totalScopeKind, totalScopeID)] {
|
||||
t.Error("total scope must never get a last-seen entry")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordLastSeen_OffWithoutStatsAdmin proves recordLastSeen (P9 fix's
|
||||
// own call site, invoked separately from checkAndCount now) builds zero
|
||||
// entries and spawns nothing when statsAdmin is false (the default for
|
||||
// every limiter built directly via newLimiter, unless a test opts in) —
|
||||
// the same "additive and default-preserving" guarantee every other family
|
||||
// in this task carries. spawn is deliberately left at its real (async,
|
||||
// goroutine-based) default: if this ever regressed to spawning an empty
|
||||
// batch, len(store.entries) staying 0 synchronously would not catch it,
|
||||
// so failing to compile a nil/empty entries slice at all (countAsync's own
|
||||
// "nil/empty entries slice spawns nothing" contract, limits.go) is what
|
||||
// this test actually pins.
|
||||
func TestRecordLastSeen_OffWithoutStatsAdmin(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.recordLastSeen([]limitScope{{kind: "user", id: "alice"}}, l.now())
|
||||
if len(store.entries) != 0 {
|
||||
t.Errorf("entries = %d, want 0 (no last-seen without statsAdmin)", len(store.entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckAndCount_LastSeen_OffWithoutStatsAdmin proves checkAndCount
|
||||
// itself never writes a last-seen entry, with or without statsAdmin — P9
|
||||
// fix, admin dashboard redesign verify round: last-seen no longer rides
|
||||
// checkAndCount's batch at all (TestCheckAndCount_NeverWritesLastSeen,
|
||||
// above, pins the statsAdmin=true case; this pins the default-off case
|
||||
// the pre-fix name of this test originally covered).
|
||||
func TestCheckAndCount_LastSeen_OffWithoutStatsAdmin(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.checkAndCount([]limitScope{{kind: "user", id: "alice"}})
|
||||
if len(store.entries) != checkAndCountKeysPerScope {
|
||||
t.Errorf("entries = %d, want %d (checkAndCount never writes last-seen any more)", len(store.entries), checkAndCountKeysPerScope)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordLastSeen_ThrottledSecondCallWithinWindowSkipsEntry proves the
|
||||
// SAME replica calling recordLastSeen twice in immediate succession (the
|
||||
// P9-fix call site, admin dashboard redesign verify round — every
|
||||
// admission call site invokes it once per admitted request) writes the
|
||||
// last-seen SET only once, via lastSeenGate.due's own throttle.
|
||||
func TestRecordLastSeen_ThrottledSecondCallWithinWindowSkipsEntry(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
now := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
|
||||
l.nowFn = func() time.Time { return now }
|
||||
l.statsAdmin = true
|
||||
l.spawn = func(f func()) { f() }
|
||||
scopes := []limitScope{{kind: "user", id: "alice"}}
|
||||
|
||||
l.recordLastSeen(scopes, now)
|
||||
if len(store.entries) != 1 {
|
||||
t.Fatalf("first call entries = %d, want 1", len(store.entries))
|
||||
}
|
||||
store.entries = nil
|
||||
l.recordLastSeen(scopes, now)
|
||||
if len(store.entries) != 0 {
|
||||
t.Errorf("second call (same instant) entries = %d, want 0 (throttled, no last-seen entry)", len(store.entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettleRejection_RejHour_OnlyWhenStatsAdmin proves settleRejection's
|
||||
// rej:hour addition (rej/hour, always-on-with-admin) rides the same
|
||||
// compensation batch only when statsAdmin is true, alongside the
|
||||
// pre-existing, unconditional rej:day entry — and that the compensation
|
||||
// entries for OTHER scopes (the rollback half) are unaffected either way.
|
||||
func TestSettleRejection_RejHour_OnlyWhenStatsAdmin(t *testing.T) {
|
||||
now := time.Date(2026, 9, 23, 10, 30, 0, 0, time.UTC)
|
||||
wb := bucketsFor(now)
|
||||
et := enforceTTLsFor()
|
||||
scopes := []limitScope{
|
||||
{kind: "user", id: "alice"},
|
||||
{kind: totalScopeKind, id: totalScopeID},
|
||||
}
|
||||
entries := []counterIncr{
|
||||
{key: windowKeyForBucket("user", "alice", metricReq, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min},
|
||||
{key: windowKeyForBucket("user", "alice", metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
|
||||
{key: windowKeyForBucket("user", "alice", metricReq, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
|
||||
{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricReq, windowMin, wb.min), delta: 1, ttl: minWindowTTL, enforceTTL: et.min},
|
||||
{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricReq, windowDay, wb.day), delta: 1, ttl: dayWindowTTL, enforceTTL: et.day},
|
||||
{key: windowKeyForBucket(totalScopeKind, totalScopeID, metricReq, windowHour, wb.hour), delta: 1, ttl: hourWindowTTL, enforceTTL: et.hour},
|
||||
}
|
||||
|
||||
for _, statsAdmin := range []bool{false, true} {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.nowFn = func() time.Time { return now }
|
||||
l.statsAdmin = statsAdmin
|
||||
l.settleRejection(entries, 0, scopes, wb, et)
|
||||
|
||||
rejHourKey := windowKeyForBucket("user", "alice", metricRej, windowHour, wb.hour)
|
||||
var gotHour bool
|
||||
for _, e := range store.entries {
|
||||
if e.key == rejHourKey {
|
||||
gotHour = true
|
||||
}
|
||||
}
|
||||
if gotHour != statsAdmin {
|
||||
t.Errorf("statsAdmin=%v: rej:hour entry present=%v, want %v", statsAdmin, gotHour, statsAdmin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountWith_UserModel_RequiresOneUserScopeAndModelScope is (a),
|
||||
// plan §2: the opt-in per-(user, model) breakdown fires only when
|
||||
// statsUserModel is on, exactly one "user" scope is present, and a
|
||||
// kindModel scope is present — writing <=8 entries (req always,
|
||||
// tokin/tokout/cost only when nonzero) at day+month, never hour.
|
||||
func TestAccountWith_UserModel_RequiresOneUserScopeAndModelScope(t *testing.T) {
|
||||
id := userModelScopeID("alice", "openai/gpt-4o")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
scopes []limitScope
|
||||
statsUserModel bool
|
||||
wantEntries bool
|
||||
}{
|
||||
{name: "on, one user scope, model scope", statsUserModel: true, wantEntries: true,
|
||||
scopes: withModelScope([]limitScope{{kind: "user", id: "alice"}}, "openai/gpt-4o")},
|
||||
{name: "flag off", statsUserModel: false, wantEntries: false,
|
||||
scopes: withModelScope([]limitScope{{kind: "user", id: "alice"}}, "openai/gpt-4o")},
|
||||
{name: "no model scope", statsUserModel: true, wantEntries: false,
|
||||
scopes: []limitScope{{kind: "user", id: "alice"}}},
|
||||
{name: "multi-group principal: two user-kind scopes never happens, but two USER scopes must still refuse",
|
||||
statsUserModel: true, wantEntries: false,
|
||||
scopes: withModelScope([]limitScope{{kind: "user", id: "alice"}, {kind: "user", id: "bob"}}, "openai/gpt-4o")},
|
||||
{name: "media: no user scope at all", statsUserModel: true, wantEntries: false,
|
||||
scopes: withModelScope(nil, "openai/gpt-4o")},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.statsAdmin = true
|
||||
l.statsUserModel = c.statsUserModel
|
||||
l.accountWith(c.scopes, usage{prompt: 10, completion: 5}, 1000, accountExtras{})
|
||||
|
||||
var got int
|
||||
for _, e := range store.entries {
|
||||
if strings.HasPrefix(e.key, "llmgw:"+kindUserModel+":"+id+":") {
|
||||
got++
|
||||
}
|
||||
}
|
||||
if c.wantEntries && got != 8 {
|
||||
t.Errorf("umodel entries = %d, want 8", got)
|
||||
}
|
||||
if !c.wantEntries && got != 0 {
|
||||
t.Errorf("umodel entries = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountWith_UserModel_ReqAlwaysEvenWithZeroUsage proves the req pair
|
||||
// is written unconditionally (mirroring kindModel's own unconditional req
|
||||
// write), even for a served request with zero reported usage.
|
||||
func TestAccountWith_UserModel_ReqAlwaysEvenWithZeroUsage(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.statsAdmin = true
|
||||
l.statsUserModel = true
|
||||
scopes := withModelScope([]limitScope{{kind: "user", id: "alice"}}, "openai/gpt-4o")
|
||||
l.accountWith(scopes, usage{}, 0, accountExtras{})
|
||||
|
||||
id := userModelScopeID("alice", "openai/gpt-4o")
|
||||
var got int
|
||||
for _, e := range store.entries {
|
||||
if strings.HasPrefix(e.key, "llmgw:"+kindUserModel+":"+id+":") && strings.Contains(e.key, ":"+metricReq+":") {
|
||||
got++
|
||||
}
|
||||
}
|
||||
if got != 2 {
|
||||
t.Errorf("umodel req entries = %d, want 2 (day+month)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountWith_Failover_IncrementsFromProvider is (c), plan §2: a
|
||||
// non-empty accountExtras.failoverFrom writes prov/{from}:fover at
|
||||
// hour+day when statsAdmin is on, never when it is off.
|
||||
func TestAccountWith_Failover_IncrementsFromProvider(t *testing.T) {
|
||||
for _, statsAdmin := range []bool{false, true} {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.statsAdmin = statsAdmin
|
||||
l.accountWith(nil, usage{}, 0, accountExtras{failoverFrom: "openai"})
|
||||
|
||||
var got int
|
||||
for _, e := range store.entries {
|
||||
if strings.HasPrefix(e.key, "llmgw:"+kindProvider+":openai:"+metricFover+":") {
|
||||
got++
|
||||
}
|
||||
}
|
||||
want := 0
|
||||
if statsAdmin {
|
||||
want = 2
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("statsAdmin=%v: fover entries = %d, want %d", statsAdmin, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLatencyBucketIndex_MatchesObserveLatencyBucket proves accountWith's
|
||||
// own bucket-index helper (limits.go) never disagrees with
|
||||
// observeLatencyBucket's in-process histogram (metrics.go) about which
|
||||
// bucket a duration belongs to — both must derive from the identical
|
||||
// latencyBucketIndex.
|
||||
func TestLatencyBucketIndex_MatchesObserveLatencyBucket(t *testing.T) {
|
||||
durations := []time.Duration{
|
||||
0, 50 * time.Millisecond, 100 * time.Millisecond, 999 * time.Millisecond,
|
||||
2 * time.Second, 30 * time.Second, 600 * time.Second, 3600 * time.Second,
|
||||
}
|
||||
for _, d := range durations {
|
||||
idx, overflow := latencyBucketIndex(d)
|
||||
|
||||
var buckets [1]int64
|
||||
full := make([]int64, len(latencyBucketBounds))
|
||||
var overflowCount int64
|
||||
var sum float64
|
||||
var count int64
|
||||
observeLatencyBucket(full, &overflowCount, &sum, &count, d)
|
||||
_ = buckets
|
||||
|
||||
if overflow {
|
||||
if overflowCount != 1 {
|
||||
t.Errorf("d=%v: latencyBucketIndex says overflow, observeLatencyBucket overflow count = %d, want 1", d, overflowCount)
|
||||
}
|
||||
if idx != len(latencyBucketBounds) {
|
||||
t.Errorf("d=%v: overflow idx = %d, want %d", d, idx, len(latencyBucketBounds))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if full[idx] != 1 {
|
||||
t.Errorf("d=%v: latencyBucketIndex says bucket %d, observeLatencyBucket did not increment it: buckets=%v", d, idx, full)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountWith_Latency_WritesProviderAndModelBuckets is (c), plan §2:
|
||||
// hasLat && statsLatency writes the serving provider's own duration
|
||||
// bucket (hour+day) plus the served model's own (when a kindModel scope
|
||||
// is present), and TTFB buckets too when the sample carries one — riding
|
||||
// the SAME storeIncrMulti batch account already builds (never a second
|
||||
// round trip: this test's own recordingStore records exactly one
|
||||
// incrMulti call for the whole accountWith invocation).
|
||||
func TestAccountWith_Latency_WritesProviderAndModelBuckets(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.statsAdmin = true
|
||||
l.statsLatency = true
|
||||
scopes := withModelScope(nil, "openai/gpt-4o")
|
||||
lat := latencySample{duration: 2 * time.Second, ttfb: 100 * time.Millisecond, hasTTFB: true}
|
||||
l.accountWith(scopes, usage{}, 0, accountExtras{provider: "openai", lat: lat, hasLat: true})
|
||||
|
||||
durIdx, _ := latencyBucketIndex(lat.duration)
|
||||
ttfbIdx, _ := latencyBucketIndex(lat.ttfb)
|
||||
wantDur := latencyDurationMetric(durIdx)
|
||||
wantTTFB := latencyTTFBMetric(ttfbIdx)
|
||||
|
||||
counts := map[string]int{}
|
||||
for _, e := range store.entries {
|
||||
counts[e.key]++
|
||||
}
|
||||
for _, kind := range []string{kindProvider, kindModel} {
|
||||
id := "openai"
|
||||
if kind == kindModel {
|
||||
id = "openai/gpt-4o"
|
||||
}
|
||||
for _, window := range []string{windowHour, windowDay} {
|
||||
for _, metric := range []string{wantDur, wantTTFB} {
|
||||
key := windowKeyForBucket(kind, id, metric, window, "x")
|
||||
// windowKeyForBucket's own bucket arg is fixed to "x" here
|
||||
// only to build a comparable prefix; match by prefix minus
|
||||
// bucket instead of exact key.
|
||||
prefix := key[:len(key)-1]
|
||||
var found bool
|
||||
for k := range counts {
|
||||
if strings.HasPrefix(k, prefix) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("missing latency entry for kind=%s id=%s metric=%s window=%s", kind, id, metric, window)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountWith_CacheMiss_TotalHourDayAndModelDayOnly is (d), plan §2:
|
||||
// cacheMiss writes total cmiss at hour+day, and the served model's own
|
||||
// cmiss at day ONLY (never hour) — gated on statsAdmin.
|
||||
func TestAccountWith_CacheMiss_TotalHourDayAndModelDayOnly(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.statsAdmin = true
|
||||
scopes := withModelScope([]limitScope{{kind: totalScopeKind, id: totalScopeID}}, "openai/gpt-4o")
|
||||
l.accountWith(scopes, usage{}, 0, accountExtras{cacheMiss: true})
|
||||
|
||||
totalHour := windowKeyForBucket(totalScopeKind, totalScopeID, metricCmiss, windowHour, bucketFor(l.now(), windowHour))
|
||||
totalDay := windowKeyForBucket(totalScopeKind, totalScopeID, metricCmiss, windowDay, bucketFor(l.now(), windowDay))
|
||||
modelDay := windowKeyForBucket(kindModel, "openai/gpt-4o", metricCmiss, windowDay, bucketFor(l.now(), windowDay))
|
||||
modelHour := windowKeyForBucket(kindModel, "openai/gpt-4o", metricCmiss, windowHour, bucketFor(l.now(), windowHour))
|
||||
|
||||
present := map[string]bool{}
|
||||
for _, e := range store.entries {
|
||||
present[e.key] = true
|
||||
}
|
||||
if !present[totalHour] || !present[totalDay] {
|
||||
t.Errorf("missing total cmiss hour/day entries: %v", store.entries)
|
||||
}
|
||||
if !present[modelDay] {
|
||||
t.Error("missing model cmiss day entry")
|
||||
}
|
||||
if present[modelHour] {
|
||||
t.Error("model cmiss hour entry present, want day only")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordCacheHit_ChitAlwaysCsaveSkippedWhenZero is recordCacheHit's
|
||||
// own contract (Q7, DECISIONS): total chit hour+day always, csave
|
||||
// hour+day only when savedMicros != 0, model chit/csave at day only.
|
||||
func TestRecordCacheHit_ChitAlwaysCsaveSkippedWhenZero(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.statsAdmin = true
|
||||
l.spawn = func(f func()) { f() } // synchronous for the test to observe entries
|
||||
|
||||
l.recordCacheHit("openai/gpt-4o", 0)
|
||||
|
||||
var chit, csave int
|
||||
for _, e := range store.entries {
|
||||
switch {
|
||||
case strings.Contains(e.key, ":"+metricChit+":"):
|
||||
chit++
|
||||
case strings.Contains(e.key, ":"+metricCsave+":"):
|
||||
csave++
|
||||
}
|
||||
}
|
||||
if chit != 3 { // total hour, total day, model day
|
||||
t.Errorf("chit entries = %d, want 3", chit)
|
||||
}
|
||||
if csave != 0 {
|
||||
t.Errorf("csave entries = %d, want 0 (saved=0)", csave)
|
||||
}
|
||||
|
||||
store.entries = nil
|
||||
l.recordCacheHit("openai/gpt-4o", 500)
|
||||
csave = 0
|
||||
for _, e := range store.entries {
|
||||
if strings.Contains(e.key, ":"+metricCsave+":") {
|
||||
csave++
|
||||
}
|
||||
}
|
||||
if csave != 3 { // total hour, total day, model day
|
||||
t.Errorf("csave entries (saved=500) = %d, want 3", csave)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordCacheHit_OffWithoutStatsAdmin proves recordCacheHit spawns
|
||||
// nothing at all when admin stats are off.
|
||||
func TestRecordCacheHit_OffWithoutStatsAdmin(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.spawn = func(f func()) { f() }
|
||||
l.recordCacheHit("openai/gpt-4o", 500)
|
||||
if len(store.entries) != 0 {
|
||||
t.Errorf("entries = %d, want 0 (statsAdmin off)", len(store.entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordUnpriced402_TotalAndModelHourDay proves recordUnpriced402
|
||||
// writes total r402 hour+day plus the refused model's own r402 hour+day,
|
||||
// gated on statsAdmin — the counter events.go's recordUnpricedRefusalEvent
|
||||
// drives from its own single chokepoint (both routes_unified.go's and
|
||||
// routes_passthrough.go's identical 402 guard).
|
||||
func TestRecordUnpriced402_TotalAndModelHourDay(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.statsAdmin = true
|
||||
l.spawn = func(f func()) { f() }
|
||||
|
||||
l.recordUnpriced402("openai/free-model")
|
||||
|
||||
present := map[string]bool{}
|
||||
for _, e := range store.entries {
|
||||
present[e.key] = true
|
||||
}
|
||||
now := l.now()
|
||||
wantKeys := []string{
|
||||
windowKeyForBucket(totalScopeKind, totalScopeID, metricR402, windowHour, bucketFor(now, windowHour)),
|
||||
windowKeyForBucket(totalScopeKind, totalScopeID, metricR402, windowDay, bucketFor(now, windowDay)),
|
||||
windowKeyForBucket(kindModel, "openai/free-model", metricR402, windowHour, bucketFor(now, windowHour)),
|
||||
windowKeyForBucket(kindModel, "openai/free-model", metricR402, windowDay, bucketFor(now, windowDay)),
|
||||
}
|
||||
for _, k := range wantKeys {
|
||||
if !present[k] {
|
||||
t.Errorf("missing r402 entry %q", k)
|
||||
}
|
||||
}
|
||||
if len(store.entries) != 4 {
|
||||
t.Errorf("total entries = %d, want 4", len(store.entries))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordProviderAttempt_HourlyAndTimeout_OnlyWithStatsAdmin proves
|
||||
// step 3: prov/provmodel attempt+fail at HOUR granularity, and prov's own
|
||||
// timeout hour+day, are added only when statsAdmin is on — riding
|
||||
// recordProviderAttempt's SAME async batch (one spawn call either way,
|
||||
// never a second round trip).
|
||||
func TestRecordProviderAttempt_HourlyAndTimeout_OnlyWithStatsAdmin(t *testing.T) {
|
||||
for _, statsAdmin := range []bool{false, true} {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.spawn = func(f func()) { f() }
|
||||
l.statsAdmin = statsAdmin
|
||||
|
||||
l.recordProviderAttempt("openai", "gpt-4o", nil, errProviderTimeout)
|
||||
|
||||
var hourAttempt, hourFail, hourTimeout, dayTimeout, modelHourAttempt int
|
||||
for _, e := range store.entries {
|
||||
switch {
|
||||
case e.key == windowKeyForBucket(kindProvider, "openai", metricProvAttempt, windowHour, bucketFor(l.now(), windowHour)):
|
||||
hourAttempt++
|
||||
case e.key == windowKeyForBucket(kindProvider, "openai", metricProvFail, windowHour, bucketFor(l.now(), windowHour)):
|
||||
hourFail++
|
||||
case e.key == windowKeyForBucket(kindProvider, "openai", metricProvTimeout, windowHour, bucketFor(l.now(), windowHour)):
|
||||
hourTimeout++
|
||||
case e.key == windowKeyForBucket(kindProvider, "openai", metricProvTimeout, windowDay, bucketFor(l.now(), windowDay)):
|
||||
dayTimeout++
|
||||
case e.key == windowKeyForBucket(kindProviderModel, providerModelScopeID("openai", "gpt-4o"), metricProvAttempt, windowHour, bucketFor(l.now(), windowHour)):
|
||||
modelHourAttempt++
|
||||
}
|
||||
}
|
||||
want := 0
|
||||
if statsAdmin {
|
||||
want = 1
|
||||
}
|
||||
if hourAttempt != want || hourFail != want || hourTimeout != want || dayTimeout != want || modelHourAttempt != want {
|
||||
t.Errorf("statsAdmin=%v: hourAttempt=%d hourFail=%d hourTimeout=%d dayTimeout=%d modelHourAttempt=%d, want %d each",
|
||||
statsAdmin, hourAttempt, hourFail, hourTimeout, dayTimeout, modelHourAttempt, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountTargetRequestsBy_CallerID_OnlyWithStatsAdmin proves step 8:
|
||||
// a non-empty caller writes kindTargetCaller req entries at day+month
|
||||
// (targetCallerScopeID("mcp", target, caller)) only when statsAdmin is
|
||||
// on, in the SAME batch countTargetRequests already builds.
|
||||
func TestCountTargetRequestsBy_CallerID_OnlyWithStatsAdmin(t *testing.T) {
|
||||
for _, statsAdmin := range []bool{false, true} {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.statsAdmin = statsAdmin
|
||||
l.countTargetRequestBy("alice", targetKindMCP, "fetch")
|
||||
|
||||
id := targetCallerScopeID(targetKindMCP, "fetch", "alice")
|
||||
var got int
|
||||
for _, e := range store.entries {
|
||||
if strings.HasPrefix(e.key, "llmgw:"+kindTargetCaller+":"+id+":") {
|
||||
got++
|
||||
}
|
||||
}
|
||||
want := 0
|
||||
if statsAdmin {
|
||||
want = 2 // day + month
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("statsAdmin=%v: tcaller entries = %d, want %d", statsAdmin, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCountTargetRequestsBy_EmptyCaller_MatchesCountTargetRequests proves
|
||||
// countTargetRequests (no caller) is byte-for-byte countTargetRequestsBy's
|
||||
// own caller="" special case — no behavior change for any pre-existing
|
||||
// caller of the old name.
|
||||
func TestCountTargetRequestsBy_EmptyCaller_MatchesCountTargetRequests(t *testing.T) {
|
||||
scopes := []limitScope{{kind: targetKindMCP, id: "fetch"}}
|
||||
|
||||
storeA := &recordingStore{}
|
||||
lA := newLimiter(storeA, true)
|
||||
lA.statsAdmin = true
|
||||
lA.nowFn = func() time.Time { return time.Unix(0, 0) }
|
||||
lA.countTargetRequests(scopes)
|
||||
|
||||
storeB := &recordingStore{}
|
||||
lB := newLimiter(storeB, true)
|
||||
lB.statsAdmin = true
|
||||
lB.nowFn = func() time.Time { return time.Unix(0, 0) }
|
||||
lB.countTargetRequestsBy("", scopes)
|
||||
|
||||
if len(storeA.entries) != len(storeB.entries) {
|
||||
t.Fatalf("len(A)=%d len(B)=%d, want equal", len(storeA.entries), len(storeB.entries))
|
||||
}
|
||||
for i := range storeA.entries {
|
||||
if storeA.entries[i].key != storeB.entries[i].key || storeA.entries[i].delta != storeB.entries[i].delta {
|
||||
t.Errorf("entry %d differs: A=%+v B=%+v", i, storeA.entries[i], storeB.entries[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHistoryBucketKeysAt_OffsetZero_MatchesHistoryBucketKeys proves
|
||||
// offset=0 is byte-for-byte historyBucketKeys' own pre-existing output.
|
||||
func TestHistoryBucketKeysAt_OffsetZero_MatchesHistoryBucketKeys(t *testing.T) {
|
||||
now := time.Date(2026, 9, 23, 10, 30, 0, 0, time.UTC)
|
||||
wantKeys, wantBuckets := historyBucketKeys(kindModel, "openai/gpt-4o", metricReq, windowDay, now, 5)
|
||||
gotKeys, gotBuckets := historyBucketKeysAt(kindModel, "openai/gpt-4o", metricReq, windowDay, now, 5, 0)
|
||||
assert.Equal(t, wantKeys, gotKeys)
|
||||
assert.Equal(t, wantBuckets, gotBuckets)
|
||||
}
|
||||
|
||||
// TestHistoryBucketKeysAt_Offset_ShiftsWholeSpanBack proves an offset
|
||||
// shifts the ENTIRE span back that many window units — the span=3,
|
||||
// offset=2 window ends exactly 2 days before the span=3, offset=0
|
||||
// window's own oldest bucket.
|
||||
func TestHistoryBucketKeysAt_Offset_ShiftsWholeSpanBack(t *testing.T) {
|
||||
now := time.Date(2026, 9, 23, 10, 30, 0, 0, time.UTC)
|
||||
_, baseBuckets := historyBucketKeysAt(kindModel, "m", metricReq, windowDay, now, 3, 0)
|
||||
_, offsetBuckets := historyBucketKeysAt(kindModel, "m", metricReq, windowDay, now, 3, 2)
|
||||
|
||||
// offsetBuckets' newest bucket must equal baseBuckets' oldest bucket
|
||||
// shifted back one more day (span=3 starting 2 days earlier ends 2
|
||||
// days earlier too).
|
||||
wantNewest := bucketFor(historyStepBack(now, windowDay, 2), windowDay)
|
||||
if offsetBuckets[len(offsetBuckets)-1] != wantNewest {
|
||||
t.Errorf("offset span's newest bucket = %s, want %s", offsetBuckets[len(offsetBuckets)-1], wantNewest)
|
||||
}
|
||||
if offsetBuckets[0] == baseBuckets[0] {
|
||||
t.Error("offset span's oldest bucket must differ from the unshifted span's")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSpanTotalsMulti_Offset_ReadsAnEarlierSpan proves spanTotalsMulti's
|
||||
// offset argument reads a DIFFERENT (earlier) set of buckets than
|
||||
// offset=0 (plan §1.3(iii)) — exercised directly against the PRODUCTION
|
||||
// reader (limits.go), the same one admin.go's chunkedModelSpanTotals/
|
||||
// chunkedModelSpanTotalsMulti and stats_read.go's readSpanTotals now all
|
||||
// share (P12 fix, admin dashboard redesign verify round).
|
||||
func TestSpanTotalsMulti_Offset_ReadsAnEarlierSpan(t *testing.T) {
|
||||
l := newLimiter(nil, true)
|
||||
now := time.Date(2026, 9, 23, 10, 0, 0, 0, time.UTC)
|
||||
id := "openai/gpt-4o"
|
||||
|
||||
l.incrCounter(kindModel, id, metricReq, windowDay, historyStepBack(now, windowDay, 3), 100, dayWindowTTL) // 3 days ago
|
||||
l.incrCounter(kindModel, id, metricReq, windowDay, historyStepBack(now, windowDay, 0), 7, dayWindowTTL) // today
|
||||
|
||||
gotNow, ok := l.spanTotalsMulti(kindModel, []string{id}, []string{metricReq}, windowDay, now, 1, 0)
|
||||
if !ok || gotNow[0][0] != 7 {
|
||||
t.Errorf("offset=0 = %v, ok=%v, want [[7]]", gotNow, ok)
|
||||
}
|
||||
|
||||
gotOffset, ok := l.spanTotalsMulti(kindModel, []string{id}, []string{metricReq}, windowDay, now, 1, 3)
|
||||
if !ok || gotOffset[0][0] != 100 {
|
||||
t.Errorf("offset=3 = %v, ok=%v, want [[100]]", gotOffset, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAccountWith_ZeroValueExtras_AddsNoFailoverLatencyOrCacheEntries is a
|
||||
// smoke test that account (accountWith with a zero-value accountExtras)
|
||||
// never adds a fover/latency/cmiss entry — those three are gated purely by
|
||||
// accountExtras fields, unlike umodel (gated by limiter flags + scope
|
||||
// shape, exercised separately above), so they must stay silent regardless
|
||||
// of statsAdmin/statsLatency when the caller passes no extras at all.
|
||||
func TestAccountWith_ZeroValueExtras_AddsNoFailoverLatencyOrCacheEntries(t *testing.T) {
|
||||
store := &recordingStore{}
|
||||
l := newLimiter(store, true)
|
||||
l.statsAdmin = true
|
||||
l.statsLatency = true
|
||||
scopes := withModelScope([]limitScope{{kind: "user", id: "alice"}}, "openai/gpt-4o")
|
||||
l.account(scopes, usage{prompt: 10, completion: 5}, 1000)
|
||||
|
||||
for _, e := range store.entries {
|
||||
if strings.Contains(e.key, ":"+metricFover+":") || strings.Contains(e.key, ":ld") || strings.Contains(e.key, ":lt") || strings.Contains(e.key, ":"+metricCmiss+":") {
|
||||
t.Errorf("unexpected extras-driven entry from account() with zero-value extras: %q", e.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user