feat(limits): unconditional usage accounting

buildLimitScopes previously omitted a user or group scope entirely
whenever that entity had no configured Limits, so a limit-less
user/group never accumulated ANY usage counters. Root-caused live:
the admin dashboard's total kept climbing while every per-user row
stayed zero until an operator added phantom, unreachable
requestsPerDay limits everywhere just to make counters build at all.

buildLimitScopes now always builds a user and a group scope. Limits
still gate enforcement only: evaluateScope already no-ops on a nil
Limits field, so a limit-less scope is counted but never violates.

Update stale doc comments coupling accounting to configured limits
(evaluateScope, the integration alice fixture, routes_media_test.go,
routes_unified_test.go, admin_test.go's admin1 assertion) and add:
- TestBuildLimitScopes_AlwaysBuildsBothScopes: table-driven over
  every nil/non-nil user/group limits combination.
- TestAdminUsage_LimitlessUserAndGroup_AccumulateRealTraffic:
  end-to-end regression proving a limit-less user and group
  accumulate real request/token counters visible via
  GET /admin/api/usage.
This commit is contained in:
2026-08-21 18:13:46 +01:00
parent 90545df588
commit 1183b6fa3c
6 changed files with 178 additions and 41 deletions
+25 -12
View File
@@ -220,11 +220,28 @@ func (g *Gateway) runUnified(w http.ResponseWriter, r *http.Request, u *user, gr
}
// buildLimitScopes returns the limitScope slice runUnified passes to the
// limiter: a user scope only when u has its own limits configured, then a
// group scope only when grp does (ruling e) — either, both, or neither may
// apply to a given request. The user scope is listed first, so
// checkAndCount reports a user's own violation ahead of their group's when
// both are breached by the same request.
// limiter: a user scope and a group scope, ALWAYS both, unconditionally
// (v0.21 fix — see this function's doc comment history below for the bug
// this closes). The user scope is listed first, so checkAndCount reports a
// user's own violation ahead of their group's when both are breached by the
// same request.
//
// u.limits or grp.limits may be nil — that scope simply carries no limit for
// evaluateScope (limits.go) to enforce, and evaluateScope's own nil-limits
// check skips it during evaluation. It is NOT omitted from the slice: usage
// accounting (checkAndCount's own counting half, and account) is
// unconditional and must never be coupled to whether a limit happens to be
// configured. Production bug (root-caused live, 2026-08-21): the previous
// version of this function omitted a user or group scope entirely whenever
// that entity had no configured Limits, which meant a limit-less user or
// group never accumulated ANY usage counters at all — the admin dashboard's
// per-user rows all read zero while the total kept climbing, until an
// operator worked around it by adding a phantom, deliberately-unreachable
// requestsPerDay limit to every user/group just to make buildLimitScopes
// build a counter scope for them. That workaround is no longer needed:
// accounting and enforcement are now fully decoupled, matching
// buildAdminUsage's own "never omits an entity for having nil limits"
// contract (admin.go) that the dashboard's usage table already promised.
//
// Callers metering actual LLM traffic wrap this result in withTotalScope
// before passing it to checkAndCount/account; handleAdminAPI (admin.go)
@@ -232,14 +249,10 @@ func (g *Gateway) runUnified(w http.ResponseWriter, r *http.Request, u *user, gr
// req/min-req/day accounting never contributes to the total-scope
// LLM-traffic series (v0.2 data-layer task).
func buildLimitScopes(u *user, grp *group) []limitScope {
scopes := make([]limitScope, 0, 2)
if u.limits != nil {
scopes = append(scopes, limitScope{limits: u.limits, kind: "user", id: u.name})
return []limitScope{
{limits: u.limits, kind: "user", id: u.name},
{limits: grp.limits, kind: "group", id: grp.name},
}
if grp.limits != nil {
scopes = append(scopes, limitScope{limits: grp.limits, kind: "group", id: grp.name})
}
return scopes
}
// withTotalScope returns scopes with the synthetic total scope