diff --git a/admin.go b/admin.go index a62504a..6038daa 100644 --- a/admin.go +++ b/admin.go @@ -2,20 +2,23 @@ package traefikllmgateway import ( "encoding/json" + "fmt" "io" "net/http" "net/url" + "strconv" "strings" "time" ) -// The three routes the read-only admin dashboard registers (spec §4, -// v0.2), matched only when adminEnabled(g.cfg) — see llmgateway.go's -// ServeHTTP dispatch. +// The four routes the read-only admin dashboard registers (spec §4, v0.2; +// adminUsageHistoryPath added by the v0.2 data-layer task), matched only +// when adminEnabled(g.cfg) — see llmgateway.go's ServeHTTP dispatch. const ( - adminPagePath = "/admin" - adminOverviewPath = "/admin/api/overview" - adminUsagePath = "/admin/api/usage" + adminPagePath = "/admin" + adminOverviewPath = "/admin/api/overview" + adminUsagePath = "/admin/api/usage" + adminUsageHistoryPath = "/admin/api/usage/history" ) // adminCSP is the Content-Security-Policy header served with all three @@ -43,9 +46,9 @@ func adminEnabled(cfg *Config) bool { return cfg.Admin != nil && cfg.Admin.Enabled } -// isAdminPath reports whether path is one of the three admin routes. +// isAdminPath reports whether path is one of the four admin routes. func isAdminPath(path string) bool { - return path == adminPagePath || path == adminOverviewPath || path == adminUsagePath + return path == adminPagePath || path == adminOverviewPath || path == adminUsagePath || path == adminUsageHistoryPath } // handleAdmin is ServeHTTP's single entry point for all three /admin* @@ -70,13 +73,21 @@ func (g *Gateway) handleAdmin(sw *statusTrackingWriter, r *http.Request) { g.handleAdminAPI(sw, r) } -// handleAdminAPI is the gate for the two /admin/api/* JSON routes, +// handleAdminAPI is the gate for the three /admin/api/* JSON routes, // applying spec §4's gate order: unauthenticated → 401, authenticated -// non-admin → 403, admin → serve. Like every other authenticated route -// it counts request counters via checkAndCount before serving: an admin -// over their own req/min limit gets a 429 here exactly as they would on -// any other route (spec §4's "Accounting: admin routes count request -// counters like any authed route"). +// non-admin → 403, admin → serve. GET /admin/api/overview and GET +// /admin/api/usage count request counters via checkAndCount before +// serving, like every other authenticated route: an admin over their own +// req/min limit gets a 429 here exactly as they would on any other route +// (spec §4's "Accounting: admin routes count request counters like any +// authed route"). +// +// GET /admin/api/usage/history (v0.2 data-layer task) is the one +// exception: it skips checkAndCount entirely. The operator-directed +// removal of admin-route stat counting (progress ledger, 2026-08-20) +// lands for every /admin/api/* route in a later task; this new route is +// built consistent with that direction from the start, rather than +// counted now and un-counted later. func (g *Gateway) handleAdminAPI(sw *statusTrackingWriter, r *http.Request) { u, grp, ok := g.auth.identify(r) g.logAuthEvent(ok, authEventUserName(u), r) @@ -88,6 +99,12 @@ func (g *Gateway) handleAdminAPI(sw *statusTrackingWriter, r *http.Request) { writeOAIError(sw, http.StatusForbidden, "invalid_request_error", "admin access required") return } + + if r.URL.Path == adminUsageHistoryPath { + g.serveAdminUsageHistory(sw, r) + return + } + if violation := g.limiter.checkAndCount(buildLimitScopes(u, grp)); violation != nil { writeLimitViolation(sw, violation) return @@ -322,12 +339,16 @@ func (g *Gateway) serveAdminOverview(w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(g.buildAdminOverview()) } -// adminUsageEntryView is one user's or group's row in GET -// /admin/api/usage (spec §4, v0.2): its current-window counter values, -// with its configured limit echoed beside each. GroupName is set only for -// a user entry (empty for a group entry) — the group a user currently -// belongs to, for display; it carries no access-control meaning of its -// own. +// adminUsageEntryView is one user's, group's, or the total scope's row in +// GET /admin/api/usage (spec §4, v0.2): its current-window counter +// values, with its configured limit echoed beside each. GroupName is set +// only for a user entry (empty for a group or the total entry) — the +// group a user currently belongs to, for display; it carries no +// access-control meaning of its own. 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 client wanting the combined total sums the two +// itself. type adminUsageEntryView struct { Limits *LimitsConfig `json:"limits,omitempty"` Kind string `json:"kind"` @@ -335,8 +356,10 @@ type adminUsageEntryView struct { GroupName string `json:"groupName,omitempty"` RequestsPerMinute int64 `json:"requestsPerMinute"` RequestsPerDay int64 `json:"requestsPerDay"` - TokensPerDay int64 `json:"tokensPerDay"` - TokensPerMonth int64 `json:"tokensPerMonth"` + TokensInPerDay int64 `json:"tokensInPerDay"` + TokensOutPerDay int64 `json:"tokensOutPerDay"` + TokensInPerMonth int64 `json:"tokensInPerMonth"` + TokensOutPerMonth int64 `json:"tokensOutPerMonth"` CostPerDayMicroUSD int64 `json:"costPerDayMicroUsd"` CostPerMonthMicroUSD int64 `json:"costPerMonthMicroUsd"` StoreDown bool `json:"storeDown,omitempty"` @@ -344,11 +367,14 @@ type adminUsageEntryView struct { // adminUsageResponse is the full body of GET /admin/api/usage: every // currently active user, and every configured group, each with its own -// usage+limits row. Both slices are sorted (authStore.snapshot's own -// contract) and built fresh per request. +// usage+limits row, plus the synthetic total scope's own row (v0.2 +// data-layer task) — usage summed across every user and group combined, +// limits always nil (see totalScopeKind). Users and Groups are both +// sorted (authStore.snapshot's own contract) and built fresh per request. type adminUsageResponse struct { Users []adminUsageEntryView `json:"users"` Groups []adminUsageEntryView `json:"groups"` + Total adminUsageEntryView `json:"total"` } // usageEntryView converts one limiter.currentUsage result plus its @@ -360,8 +386,10 @@ func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView { Limits: limits, RequestsPerMinute: su.requestsPerMinute, RequestsPerDay: su.requestsPerDay, - TokensPerDay: su.tokensPerDay, - TokensPerMonth: su.tokensPerMonth, + TokensInPerDay: su.tokensInPerDay, + TokensOutPerDay: su.tokensOutPerDay, + TokensInPerMonth: su.tokensInPerMonth, + TokensOutPerMonth: su.tokensOutPerMonth, CostPerDayMicroUSD: su.costPerDayMicros, CostPerMonthMicroUSD: su.costPerMonthMicros, StoreDown: su.storeDown, @@ -371,28 +399,32 @@ func usageEntryView(su scopeUsage, limits *LimitsConfig) adminUsageEntryView { // buildAdminUsage assembles adminUsageResponse: authStore.snapshot lists // every currently active user and every configured group (names, group // membership, and limits only — never a key or its digest), and -// limiter.currentUsage reads every one's six current-window counters in -// ONE storeGetMulti round trip total (v0.2 final review wave, 2026-08-20; -// see limiter.currentUsage's own doc comment for the tradeoff this -// supersedes) — users and groups are concatenated into a single scopes -// slice before that one call, then the flat result is sliced back into -// the two response sections at the same split point, order preserved. -// Unlike buildLimitScopes (routes_unified.go), this never omits an -// entity for having nil limits — the dashboard shows usage for every -// user and group, limited or not. +// limiter.currentUsage reads every one's current-window counters — plus +// the synthetic total scope's own (v0.2 data-layer task) — in ONE +// storeGetMulti round trip total (v0.2 final review wave, 2026-08-20; see +// limiter.currentUsage's own doc comment for the tradeoff this +// supersedes) — users, groups, and the total scope are concatenated into +// a single scopes slice before that one call, then the flat result is +// sliced back into the three response sections at the same split points, +// order preserved. Unlike buildLimitScopes (routes_unified.go), this +// never omits an entity for having nil limits — the dashboard shows usage +// for every user and group, limited or not. func (g *Gateway) buildAdminUsage() adminUsageResponse { userSummaries, groupSummaries := g.auth.snapshot() - scopes := make([]limitScope, 0, len(userSummaries)+len(groupSummaries)) + scopes := make([]limitScope, 0, len(userSummaries)+len(groupSummaries)+1) for _, us := range userSummaries { scopes = append(scopes, limitScope{kind: "user", id: us.name, limits: us.limits}) } for _, gs := range groupSummaries { scopes = append(scopes, limitScope{kind: "group", id: gs.name, limits: gs.limits}) } + scopes = append(scopes, limitScope{kind: totalScopeKind, id: totalScopeID, limits: nil}) allUsage := g.limiter.currentUsage(scopes) - userUsage, groupUsage := allUsage[:len(userSummaries)], allUsage[len(userSummaries):] + userUsage := allUsage[:len(userSummaries)] + groupUsage := allUsage[len(userSummaries) : len(userSummaries)+len(groupSummaries)] + totalUsage := allUsage[len(userSummaries)+len(groupSummaries)] users := make([]adminUsageEntryView, len(userUsage)) for i, su := range userUsage { @@ -403,8 +435,9 @@ func (g *Gateway) buildAdminUsage() adminUsageResponse { for i, su := range groupUsage { groups[i] = usageEntryView(su, groupSummaries[i].limits) } + total := usageEntryView(totalUsage, nil) - return adminUsageResponse{Users: users, Groups: groups} + return adminUsageResponse{Users: users, Groups: groups, Total: total} } // serveAdminUsage writes buildAdminUsage's result as JSON. @@ -413,6 +446,189 @@ func (g *Gateway) serveAdminUsage(w http.ResponseWriter) { _ = json.NewEncoder(w).Encode(g.buildAdminUsage()) } +// historyMaxSpan caps GET /admin/api/usage/history's span query parameter +// per window (v0.2 data-layer task), matching each window's own retention +// (limits.go: hourWindowTTL/dayWindowTTL/monthWindowTTL) rounded down to +// whole buckets: 48 hourly buckets, 35 daily buckets, 13 monthly buckets +// (a one-month margin over monthWindowTTL's 400*24h ≈ 13.3 months). A +// span beyond what its window's TTL could ever have kept alive would only +// echo zeros for the missing tail, so the cap keeps every requested +// bucket meaningful. window is assumed already validated by +// validHistoryWindow — the default case is a programming error, not a +// client input, mirroring windowKey/windowEnd's own panic convention. +func historyMaxSpan(window string) int { + switch window { + case windowHour: + return 48 + case windowDay: + return 35 + case windowMonth: + return 13 + default: + panic(fmt.Sprintf("llmgateway: historyMaxSpan: unknown window %q", window)) + } +} + +// validHistoryMetric reports whether metric is one of GET +// /admin/api/usage/history's four accepted metric values. +func validHistoryMetric(metric string) bool { + switch metric { + case metricReq, metricTokIn, metricTokOut, metricCost: + return true + default: + return false + } +} + +// validHistoryWindow reports whether window is one of GET +// /admin/api/usage/history's three accepted window values. +func validHistoryWindow(window string) bool { + switch window { + case windowHour, windowDay, windowMonth: + return true + default: + return false + } +} + +// parseHistoryScope parses GET /admin/api/usage/history's "scope" query +// parameter: "user:{id}", "group:{id}", or the literal "total". ok is +// false for anything else, including a bare "total:{id}" form — the total +// scope carries no id component of its own, it is always totalScopeID +// (limits.go). +func parseHistoryScope(raw string) (kind, id string, ok bool) { + if raw == totalScopeKind { + return totalScopeKind, totalScopeID, true + } + k, i, found := strings.Cut(raw, ":") + if !found || i == "" { + return "", "", false + } + if k != "user" && k != "group" { + return "", "", false + } + return k, i, true +} + +// parseHistorySpan parses GET /admin/api/usage/history's "span" query +// parameter for window: an empty raw value defaults to window's own +// historyMaxSpan; otherwise raw must parse as an integer between 1 and +// that max inclusive. ok is false for anything else — a non-integer, a +// value below 1, or a value beyond window's max. +func parseHistorySpan(raw, window string) (span int, ok bool) { + max := historyMaxSpan(window) + if raw == "" { + return max, true + } + n, err := strconv.Atoi(raw) + if err != nil || n < 1 || n > max { + return 0, false + } + return n, true +} + +// scopeExists reports whether kind/id names a currently active user or a +// configured group — GET /admin/api/usage/history's 404 check for an +// unknown scope id. authStore.snapshot's own listing is the same +// membership buildAdminUsage already trusts for "every currently active +// user and every configured group"; kind is assumed already restricted to +// "user" or "group" by parseHistoryScope (the total scope's id is never +// checked against it — totalScopeID always exists). +func (g *Gateway) scopeExists(kind, id string) bool { + users, groups := g.auth.snapshot() + switch kind { + case "user": + for _, u := range users { + if u.name == id { + return true + } + } + case "group": + for _, gr := range groups { + if gr.name == id { + return true + } + } + } + return false +} + +// usageHistoryPointView is one bucket in GET /admin/api/usage/history's +// points array. +type usageHistoryPointView struct { + Bucket string `json:"bucket"` + Value int64 `json:"value"` +} + +// usageHistoryResponse is the full body of GET /admin/api/usage/history +// (v0.2 data-layer task): scope/metric/window echoed back exactly as +// validated (scope in its raw "user:{id}"/"group:{id}"/"total" query +// form), plus span buckets oldest-first — Points[len-1] is the current, +// possibly partial, bucket. +type usageHistoryResponse struct { + Scope string `json:"scope"` + Metric string `json:"metric"` + Window string `json:"window"` + Points []usageHistoryPointView `json:"points"` +} + +// serveAdminUsageHistory writes GET /admin/api/usage/history's bucketed +// series for one scope/metric/window (v0.2 data-layer task, the Vue admin +// panel's chart data source): "scope"=user:{id}|group:{id}|total, +// "metric"=req|tokin|tokout|cost, "window"=hour|day|month, optional +// "span"=N (default and max per historyMaxSpan). Validates every +// parameter before touching the store — 400 for an unrecognized scope +// kind/metric/window or an out-of-range span, 404 for a user/group id +// that names no configured entity — then reads the series via +// limiter.history, ONE storeGetMulti round trip for the whole span. A +// storeDown read answers 503, not a silently-zero series: a chart built +// on this API must never mistake "the store was unreachable" for "usage +// was genuinely zero". checkAndCount is deliberately never called here — +// see handleAdminAPI's own doc comment. +func (g *Gateway) serveAdminUsageHistory(sw *statusTrackingWriter, r *http.Request) { + q := r.URL.Query() + + rawScope := q.Get("scope") + kind, id, ok := parseHistoryScope(rawScope) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown scope") + return + } + metric := q.Get("metric") + if !validHistoryMetric(metric) { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown metric") + return + } + window := q.Get("window") + if !validHistoryWindow(window) { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "unknown window") + return + } + span, ok := parseHistorySpan(q.Get("span"), window) + if !ok { + writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid span") + return + } + if kind != totalScopeKind && !g.scopeExists(kind, id) { + writeOAIError(sw, http.StatusNotFound, "invalid_request_error", "unknown "+kind+" id") + return + } + + points, storeOK := g.limiter.history(kind, id, metric, window, g.limiter.now(), span) + if !storeOK { + writeOAIError(sw, http.StatusServiceUnavailable, "server_error", "usage history store unavailable") + return + } + + view := make([]usageHistoryPointView, len(points)) + for i, p := range points { + view[i] = usageHistoryPointView{Bucket: p.bucket, Value: p.value} + } + + setAdminJSONHeaders(sw) + _ = json.NewEncoder(sw).Encode(usageHistoryResponse{Scope: rawScope, Metric: metric, Window: window, Points: view}) +} + // adminPageHTML is the entire /admin single-page dashboard: markup, CSS, // and vanilla JS in one Go raw-string const. Yaegi interpretation forbids // the embed directive (no filesystem access from an interpreted plugin), @@ -531,14 +747,14 @@ const adminPageHTML = `

Groups

- +
NameMembersLimitsreq/minreq/daytok/daytok/monthcost/daycost/monthNameMembersLimitsreq/minreq/daytokIn/daytokOut/daytokIn/monthtokOut/monthcost/daycost/month

Users

- +
NameGroupLimitsreq/minreq/daytok/daytok/monthcost/daycost/monthNameGroupLimitsreq/minreq/daytokIn/daytokOut/daytokIn/monthtokOut/monthcost/daycost/month
@@ -660,8 +876,10 @@ const adminPageHTML = ` tr.appendChild(el("td", fmtLimits(entry.limits))); tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.requestsPerMinute))); tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.requestsPerDay))); - tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.tokensPerDay))); - tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.tokensPerMonth))); + tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.tokensInPerDay))); + tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.tokensOutPerDay))); + tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.tokensInPerMonth))); + tr.appendChild(el("td", entry.storeDown ? "?" : String(entry.tokensOutPerMonth))); tr.appendChild(el("td", entry.storeDown ? "?" : fmtCost(entry.costPerDayMicroUsd))); tr.appendChild(el("td", entry.storeDown ? "?" : fmtCost(entry.costPerMonthMicroUsd))); if (entry.storeDown) tr.className = "err"; diff --git a/admin_test.go b/admin_test.go index 72f4e9f..9f2e603 100644 --- a/admin_test.go +++ b/admin_test.go @@ -381,6 +381,12 @@ func TestAdminUsage_MathAgainstSeededCounters(t *testing.T) { gw.limiter.incrCounter("group", "agroup", metricReq, windowDay, fixedNow, 9, dayWindowTTL) gw.limiter.account([]limitScope{{kind: "group", id: "agroup"}}, usage{prompt: 100, completion: 50}, 9_000_000) + // Seed the synthetic total scope directly too (v0.2 data-layer task): + // buildAdminUsage always includes it, independent of whether any + // route's withTotalScope call ever ran in this test. + gw.limiter.incrCounter(totalScopeKind, totalScopeID, metricReq, windowDay, fixedNow, 16, dayWindowTTL) + gw.limiter.account([]limitScope{{kind: totalScopeKind, id: totalScopeID}}, usage{prompt: 140, completion: 60}, 11_500_000) + rec := httptest.NewRecorder() h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsagePath, "sk-admin1")) if rec.Code != http.StatusOK { @@ -396,8 +402,9 @@ func TestAdminUsage_MathAgainstSeededCounters(t *testing.T) { if alice.RequestsPerMinute != 3 || alice.RequestsPerDay != 7 { t.Errorf("alice requests = min:%d day:%d, want min:3 day:7", alice.RequestsPerMinute, alice.RequestsPerDay) } - if alice.TokensPerDay != 50 || alice.TokensPerMonth != 50 { - t.Errorf("alice tokens = day:%d month:%d, want 50/50", alice.TokensPerDay, alice.TokensPerMonth) + if alice.TokensInPerDay != 40 || alice.TokensOutPerDay != 10 || alice.TokensInPerMonth != 40 || alice.TokensOutPerMonth != 10 { + t.Errorf("alice tokens = in/day:%d out/day:%d in/month:%d out/month:%d, want 40/10/40/10", + alice.TokensInPerDay, alice.TokensOutPerDay, alice.TokensInPerMonth, alice.TokensOutPerMonth) } if alice.CostPerDayMicroUSD != 2_500_000 || alice.CostPerMonthMicroUSD != 2_500_000 { t.Errorf("alice cost = day:%d month:%d, want 2500000/2500000", alice.CostPerDayMicroUSD, alice.CostPerMonthMicroUSD) @@ -416,8 +423,9 @@ func TestAdminUsage_MathAgainstSeededCounters(t *testing.T) { if agroup.RequestsPerMinute != 5 || agroup.RequestsPerDay != 9 { t.Errorf("agroup requests = min:%d day:%d, want min:5 day:9", agroup.RequestsPerMinute, agroup.RequestsPerDay) } - if agroup.TokensPerDay != 150 || agroup.TokensPerMonth != 150 { - t.Errorf("agroup tokens = day:%d month:%d, want 150/150", agroup.TokensPerDay, agroup.TokensPerMonth) + if agroup.TokensInPerDay != 100 || agroup.TokensOutPerDay != 50 || agroup.TokensInPerMonth != 100 || agroup.TokensOutPerMonth != 50 { + t.Errorf("agroup tokens = in/day:%d out/day:%d in/month:%d out/month:%d, want 100/50/100/50", + agroup.TokensInPerDay, agroup.TokensOutPerDay, agroup.TokensInPerMonth, agroup.TokensOutPerMonth) } if agroup.CostPerDayMicroUSD != 9_000_000 { t.Errorf("agroup cost/day = %d, want 9000000", agroup.CostPerDayMicroUSD) @@ -435,6 +443,28 @@ func TestAdminUsage_MathAgainstSeededCounters(t *testing.T) { if admin1.StoreDown { t.Error("admin1.storeDown must be false") } + + // The synthetic total scope row (v0.2 data-layer task): present, + // carries no limit (limits.go's totalScopeKind is never evaluated), + // and reflects exactly the counters seeded above. + if got.Total.Kind != totalScopeKind || got.Total.ID != totalScopeID { + t.Errorf("total kind/id = %q/%q, want %q/%q", got.Total.Kind, got.Total.ID, totalScopeKind, totalScopeID) + } + if got.Total.Limits != nil { + t.Errorf("total limits = %+v, want nil (the total scope is never evaluated)", got.Total.Limits) + } + if got.Total.RequestsPerDay != 16 { + t.Errorf("total requestsPerDay = %d, want 16", got.Total.RequestsPerDay) + } + if got.Total.TokensInPerDay != 140 || got.Total.TokensOutPerDay != 60 { + t.Errorf("total tokens = in/day:%d out/day:%d, want 140/60", got.Total.TokensInPerDay, got.Total.TokensOutPerDay) + } + if got.Total.CostPerDayMicroUSD != 11_500_000 { + t.Errorf("total cost/day = %d, want 11500000", got.Total.CostPerDayMicroUSD) + } + if got.Total.StoreDown { + t.Error("total.storeDown must be false") + } } func findUsageEntry(t *testing.T, entries []adminUsageEntryView, id string) adminUsageEntryView { @@ -466,8 +496,9 @@ func TestLimiterCurrentUsage_StoreDown(t *testing.T) { if !su.storeDown { t.Fatal("want storeDown true when the configured store errors and failOpen is false") } - if su.requestsPerMinute != 0 || su.requestsPerDay != 0 || su.tokensPerDay != 0 || - su.tokensPerMonth != 0 || su.costPerDayMicros != 0 || su.costPerMonthMicros != 0 { + if su.requestsPerMinute != 0 || su.requestsPerDay != 0 || su.tokensInPerDay != 0 || + su.tokensInPerMonth != 0 || su.tokensOutPerDay != 0 || su.tokensOutPerMonth != 0 || + su.costPerDayMicros != 0 || su.costPerMonthMicros != 0 { t.Errorf("storeDown entry must report zero values, got %+v", su) } } @@ -698,11 +729,11 @@ func TestAdminOverview_BaseURLStripsCredentials(t *testing.T) { // countingMultiStore is a counterStore stub whose getMulti serves a fixed // value per key and counts how many times it was called — it proves -// limiter.currentUsage flattens every scope's six counter reads into ONE -// getMulti call total (v0.2 final review wave, 2026-08-20), rather than -// six separate get calls or even one getMulti call per scope. -// incrBy/get are never exercised by currentUsage and just return zero -// values. +// limiter.currentUsage flattens every scope's usageKeysPerScope counter +// reads into ONE getMulti call total (v0.2 final review wave, +// 2026-08-20), rather than usageKeysPerScope separate get calls or even +// one getMulti call per scope. incrBy/get are never exercised by +// currentUsage and just return zero values. type countingMultiStore struct { values map[string]int64 getMultiCalls int @@ -725,13 +756,15 @@ func TestLimiterCurrentUsage_BatchesOneGetMultiCallForAllScopes(t *testing.T) { t.Parallel() fixedNow := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) store := &countingMultiStore{values: map[string]int64{ - windowKey("user", "alice", metricReq, windowMin, fixedNow): 3, - windowKey("user", "alice", metricReq, windowDay, fixedNow): 7, - windowKey("user", "alice", metricTok, windowDay, fixedNow): 50, - windowKey("user", "alice", metricTok, windowMonth, fixedNow): 60, - windowKey("user", "alice", metricCost, windowDay, fixedNow): 1_000, - windowKey("user", "alice", metricCost, windowMonth, fixedNow): 2_000, - windowKey("group", "g1", metricReq, windowMin, fixedNow): 1, + windowKey("user", "alice", metricReq, windowMin, fixedNow): 3, + windowKey("user", "alice", metricReq, windowDay, fixedNow): 7, + windowKey("user", "alice", metricTokIn, windowDay, fixedNow): 50, + windowKey("user", "alice", metricTokIn, windowMonth, fixedNow): 60, + windowKey("user", "alice", metricTokOut, windowDay, fixedNow): 20, + windowKey("user", "alice", metricTokOut, windowMonth, fixedNow): 25, + windowKey("user", "alice", metricCost, windowDay, fixedNow): 1_000, + windowKey("user", "alice", metricCost, windowMonth, fixedNow): 2_000, + windowKey("group", "g1", metricReq, windowMin, fixedNow): 1, }} l := newLimiter(store, true) l.nowFn = func() time.Time { return fixedNow } @@ -752,8 +785,9 @@ func TestLimiterCurrentUsage_BatchesOneGetMultiCallForAllScopes(t *testing.T) { if alice.storeDown { t.Fatal("alice.storeDown must be false") } - if alice.requestsPerMinute != 3 || alice.requestsPerDay != 7 || alice.tokensPerDay != 50 || - alice.tokensPerMonth != 60 || alice.costPerDayMicros != 1_000 || alice.costPerMonthMicros != 2_000 { + if alice.requestsPerMinute != 3 || alice.requestsPerDay != 7 || alice.tokensInPerDay != 50 || + alice.tokensInPerMonth != 60 || alice.tokensOutPerDay != 20 || alice.tokensOutPerMonth != 25 || + alice.costPerDayMicros != 1_000 || alice.costPerMonthMicros != 2_000 { t.Errorf("alice usage = %+v, want the seeded values", alice) } g1 := got[1] @@ -839,3 +873,240 @@ func TestAdmin_FileUserAdminFlag_GrantsAccess(t *testing.T) { t.Fatalf("status = %d, want 200 for a file-sourced admin user, body=%s", rec.Code, rec.Body.String()) } } + +// --- usage/history: gate, validation, shape, batching, storeDown (v0.2 data-layer task) --- + +// TestAdminUsageHistory_GateMatrix mirrors TestAdmin_GateMatrix for the +// third /admin/api/* route: unauthenticated → 401, authenticated +// non-admin → 403, admin → 200. +func TestAdminUsageHistory_GateMatrix(t *testing.T) { + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + query := adminUsageHistoryPath + "?scope=total&metric=req&window=hour" + + t.Run("unauthenticated", func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, query, "")) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } + assertOAIErrorEnvelope(t, rec) + }) + t.Run("authenticated_non_admin", func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, query, "sk-alice")) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + assertOAIErrorEnvelope(t, rec) + }) + t.Run("admin", func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, query, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + }) +} + +// TestAdminUsageHistory_DoesNotCountStats proves GET +// /admin/api/usage/history skips checkAndCount entirely (unlike +// overview/usage — handleAdminAPI's own doc comment, admin.go): repeated +// requests never move admin1's own req/min counter, and the route stays +// reachable even once admin1 is already pinned at a requestsPerMinute +// limit tight enough to 429 on the other two admin routes. +func TestAdminUsageHistory_DoesNotCountStats(t *testing.T) { + cfg := newAdminTestConfig() + cfg.Users.Inline[1].Limits = &LimitsConfig{RequestsPerMinute: 1} // admin1 + h, gw := newAdminGatewayHandle(t, cfg) + + query := adminUsageHistoryPath + "?scope=total&metric=req&window=hour" + for i := 0; i < 5; i++ { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, query, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("request %d: status = %d, want 200 (never 429), body=%s", i, rec.Code, rec.Body.String()) + } + } + reqCount, ok := gw.limiter.getCounter("user", "admin1", metricReq, windowMin, time.Now()) + if !ok || reqCount != 0 { + t.Errorf("admin1 req/min counter = %d (ok=%v), want 0 (history route never calls checkAndCount)", reqCount, ok) + } +} + +// TestAdminUsageHistory_ValidationTable drives GET +// /admin/api/usage/history's query-parameter validation end to end: an +// unrecognized scope kind/metric/window or an out-of-range span is 400, +// an unknown user/group id is 404, and every well-formed combination +// (including span pinned exactly at its window's max) is 200. +func TestAdminUsageHistory_ValidationTable(t *testing.T) { + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + cases := []struct { + name string + query string + want int + }{ + {"unknown scope kind", "scope=nope&metric=req&window=hour", http.StatusBadRequest}, + {"total scope rejects an id suffix", "scope=total:all&metric=req&window=hour", http.StatusBadRequest}, + {"scope missing a colon", "scope=user&metric=req&window=hour", http.StatusBadRequest}, + {"scope with an empty id", "scope=user:&metric=req&window=hour", http.StatusBadRequest}, + {"unknown metric", "scope=total&metric=nope&window=hour", http.StatusBadRequest}, + {"unknown window", "scope=total&metric=req&window=nope", http.StatusBadRequest}, + {"span not an integer", "scope=total&metric=req&window=hour&span=abc", http.StatusBadRequest}, + {"span zero", "scope=total&metric=req&window=hour&span=0", http.StatusBadRequest}, + {"span negative", "scope=total&metric=req&window=hour&span=-1", http.StatusBadRequest}, + {"span beyond hour max (48)", "scope=total&metric=req&window=hour&span=49", http.StatusBadRequest}, + {"span beyond day max (35)", "scope=total&metric=req&window=day&span=36", http.StatusBadRequest}, + {"span beyond month max (13)", "scope=total&metric=req&window=month&span=14", http.StatusBadRequest}, + {"span pinned exactly at the hour max is valid", "scope=total&metric=req&window=hour&span=48", http.StatusOK}, + {"unknown user id", "scope=user:ghost&metric=req&window=hour", http.StatusNotFound}, + {"unknown group id", "scope=group:ghost&metric=req&window=hour", http.StatusNotFound}, + {"valid total/req/hour, default span", "scope=total&metric=req&window=hour", http.StatusOK}, + {"valid user/tokin/day", "scope=user:alice&metric=tokin&window=day", http.StatusOK}, + {"valid group/tokout/month", "scope=group:agroup&metric=tokout&window=month", http.StatusOK}, + {"valid cost metric", "scope=total&metric=cost&window=day", http.StatusOK}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageHistoryPath+"?"+c.query, "sk-admin1")) + if rec.Code != c.want { + t.Errorf("status = %d, want %d, body=%s", rec.Code, c.want, rec.Body.String()) + } + if c.want >= 400 { + assertOAIErrorEnvelope(t, rec) + } + }) + } +} + +// TestAdminUsageHistory_DefaultSpanIsMax asserts an omitted span defaults +// to each window's own historyMaxSpan. +func TestAdminUsageHistory_DefaultSpanIsMax(t *testing.T) { + cfg := newAdminTestConfig() + h, _ := newAdminGatewayHandle(t, cfg) + + cases := []struct { + window string + want int + }{ + {windowHour, 48}, + {windowDay, 35}, + {windowMonth, 13}, + } + for _, c := range cases { + t.Run(c.window, func(t *testing.T) { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, adminRequest(http.MethodGet, adminUsageHistoryPath+"?scope=total&metric=req&window="+c.window, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got usageHistoryResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Points) != c.want { + t.Errorf("len(points) = %d, want %d (default span = max)", len(got.Points), c.want) + } + }) + } +} + +// TestAdminUsageHistory_ShapeOldestFirstOneBatchCall drives the route +// against a limiter whose store is swapped for a counting stub: it must +// decode to the documented {"scope","metric","window","points":[...]} +// shape, points must be oldest-first with the current (possibly partial) +// bucket last, and the whole span must cost exactly ONE getMulti call — +// never one call per bucket. +func TestAdminUsageHistory_ShapeOldestFirstOneBatchCall(t *testing.T) { + cfg := newAdminTestConfig() + h, gw := newAdminGatewayHandle(t, cfg) + + fixedNow := time.Date(2026, 8, 20, 14, 0, 0, 0, time.UTC) + countStore := &countingMultiStore{values: map[string]int64{ + windowKey(totalScopeKind, totalScopeID, metricReq, windowHour, fixedNow.Add(-2*time.Hour)): 5, + windowKey(totalScopeKind, totalScopeID, metricReq, windowHour, fixedNow.Add(-1*time.Hour)): 7, + windowKey(totalScopeKind, totalScopeID, metricReq, windowHour, fixedNow): 9, + }} + gw.limiter = newLimiter(countStore, true) + gw.limiter.nowFn = func() time.Time { return fixedNow } + + rec := httptest.NewRecorder() + query := adminUsageHistoryPath + "?scope=total&metric=req&window=hour&span=3" + h.ServeHTTP(rec, adminRequest(http.MethodGet, query, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + if countStore.getMultiCalls != 1 { + t.Errorf("getMultiCalls = %d, want 1 (one batch for the whole span)", countStore.getMultiCalls) + } + + var got usageHistoryResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if got.Scope != "total" || got.Metric != "req" || got.Window != "hour" { + t.Errorf("echoed scope/metric/window = %q/%q/%q, want total/req/hour", got.Scope, got.Metric, got.Window) + } + if len(got.Points) != 3 { + t.Fatalf("len(points) = %d, want 3", len(got.Points)) + } + wantValues := []int64{5, 7, 9} + for i, want := range wantValues { + if got.Points[i].Value != want { + t.Errorf("points[%d].value = %d, want %d (oldest-first)", i, got.Points[i].Value, want) + } + } + if got.Points[2].Bucket != bucketFor(fixedNow, windowHour) { + t.Errorf("points[2].bucket = %q, want the current bucket %q", got.Points[2].Bucket, bucketFor(fixedNow, windowHour)) + } +} + +// TestAdminUsageHistory_StoreDown503 asserts a fail-closed store error +// answers 503 with the standard OAI-shaped error envelope, never a +// silently-zero series — data unavailable beats a chart that lies about +// zero usage. +func TestAdminUsageHistory_StoreDown503(t *testing.T) { + cfg := newAdminTestConfig() + failOpen := false + cfg.Redis = &RedisConfig{Address: "127.0.0.1:1", FailOpen: &failOpen} + h, _ := newAdminGatewayHandle(t, cfg) + + rec := httptest.NewRecorder() + query := adminUsageHistoryPath + "?scope=total&metric=req&window=hour" + h.ServeHTTP(rec, adminRequest(http.MethodGet, query, "sk-admin1")) + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("status = %d, want 503, body=%s", rec.Code, rec.Body.String()) + } + assertOAIErrorEnvelope(t, rec) +} + +// TestAdminUsageHistory_MemoryStoreFallbackWorks proves the history API +// serves real data through the in-process memoryStore fallback too — a +// deployment with no Redis configured at all still gets charts of +// whatever the process itself accumulated (spec's "memoryStore works +// too" requirement). +func TestAdminUsageHistory_MemoryStoreFallbackWorks(t *testing.T) { + cfg := newAdminTestConfig() // cfg.Redis left nil: limiter.store is nil, every op uses the fallback + h, gw := newAdminGatewayHandle(t, cfg) + + fixedNow := time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC) + gw.limiter.nowFn = func() time.Time { return fixedNow } + gw.limiter.account([]limitScope{{kind: totalScopeKind, id: totalScopeID}}, usage{prompt: 12, completion: 4}, 0) + + rec := httptest.NewRecorder() + query := adminUsageHistoryPath + "?scope=total&metric=tokin&window=hour&span=1" + h.ServeHTTP(rec, adminRequest(http.MethodGet, query, "sk-admin1")) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200, body=%s", rec.Code, rec.Body.String()) + } + var got usageHistoryResponse + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode: %v", err) + } + if len(got.Points) != 1 || got.Points[0].Value != 12 { + t.Errorf("points = %+v, want one point with value 12", got.Points) + } +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 1f81320..cb282ee 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -757,6 +757,29 @@ func adminUsageEntry(t *testing.T, kind, id string) map[string]any { return nil } +// adminTotalTokensPerDay sums an admin usage entry's tokensInPerDay and +// tokensOutPerDay (v0.2 data-layer task: the JSON API split what was +// previously a single combined tokensPerDay field into in/out — see +// adminUsageEntryView, admin.go). +func adminTotalTokensPerDay(entry map[string]any) float64 { + in, _ := entry["tokensInPerDay"].(float64) + out, _ := entry["tokensOutPerDay"].(float64) + return in + out +} + +// adminUsageHistory issues GET /admin/api/usage/history as adminKey for +// scope/metric/window and returns the decoded response body — the v0.2 +// data-layer task's third admin API. +func adminUsageHistory(t *testing.T, scope, metric, window string) map[string]any { + t.Helper() + url := traefik1URL + "/admin/api/usage/history?scope=" + scope + "&metric=" + metric + "&window=" + window + resp, body := doJSON(t, http.MethodGet, url, adminKey, nil) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET /admin/api/usage/history (scope=%s metric=%s window=%s): status = %d, body=%#v", scope, metric, window, resp.StatusCode, body) + } + return body +} + // TestResponseCache covers v0.2 integration coverage: two identical chat // completions get X-Llmgw-Cache: miss then hit, and the admin usage API // (spec §4) proves the hit added zero tokens — only the miss's real @@ -784,9 +807,9 @@ func TestResponseCache(t *testing.T) { t.Error("redis holds no llmgw:cache:* key after a cache miss, want the entry the miss's SET (cache.go) should have written") } - tokensAfterMiss, _ := adminUsageEntry(t, "users", "alice")["tokensPerDay"].(float64) + tokensAfterMiss := adminTotalTokensPerDay(adminUsageEntry(t, "users", "alice")) if tokensAfterMiss <= 0 { - t.Fatalf("tokensPerDay after the miss = %v, want > 0 (the mock's real usage should have been accounted)", tokensAfterMiss) + t.Fatalf("tokensInPerDay+tokensOutPerDay after the miss = %v, want > 0 (the mock's real usage should have been accounted)", tokensAfterMiss) } resp2, body2 := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", aliceKey, reqBody) @@ -800,9 +823,9 @@ func TestResponseCache(t *testing.T) { t.Errorf("second request: content = %q, want the cached %q", content, "mock openai response") } - tokensAfterHit, _ := adminUsageEntry(t, "users", "alice")["tokensPerDay"].(float64) + tokensAfterHit := adminTotalTokensPerDay(adminUsageEntry(t, "users", "alice")) if tokensAfterHit != tokensAfterMiss { - t.Errorf("tokensPerDay changed across the cache hit: after miss = %v, after hit = %v, want unchanged", tokensAfterMiss, tokensAfterHit) + t.Errorf("tokensInPerDay+tokensOutPerDay changed across the cache hit: after miss = %v, after hit = %v, want unchanged", tokensAfterMiss, tokensAfterHit) } } @@ -978,4 +1001,27 @@ func TestAdminDashboard(t *testing.T) { if reqDay, _ := adminUsage["requestsPerDay"].(float64); reqDay <= 0 { t.Errorf("admin requestsPerDay = %v, want > 0 (this test's own admin API calls count too)", reqDay) } + + // v0.2 data-layer task: GET /admin/api/usage/history's hour series for + // alice and for the synthetic total scope both must show a non-zero + // current-hour bucket, reflecting the real LLM traffic this suite + // already generated (TestRetryFlakyRecovers, TestResponseCache, ...) + // against a real Redis-backed limiter, not just a unit-test stub. + for _, scope := range []string{"user:alice", "total"} { + hist := adminUsageHistory(t, scope, "req", "hour") + if got, _ := hist["scope"].(string); got != scope { + t.Errorf("history scope=%q: echoed scope = %q, want %q", scope, got, scope) + } + points, ok := hist["points"].([]any) + if !ok || len(points) == 0 { + t.Fatalf("history scope=%q: points = %#v, want a non-empty array", scope, hist["points"]) + } + current, ok := points[len(points)-1].(map[string]any) + if !ok { + t.Fatalf("history scope=%q: last point is not an object, got %#v", scope, points[len(points)-1]) + } + if val, _ := current["value"].(float64); val <= 0 { + t.Errorf("history scope=%q: current-hour bucket value = %v, want > 0 (this suite's own traffic)", scope, val) + } + } } diff --git a/limits.go b/limits.go index b68f4e1..f78d782 100644 --- a/limits.go +++ b/limits.go @@ -27,18 +27,30 @@ const storeErrorLogEvery = 30 * time.Second const storeDownLatchFor = 5 * time.Second // Window identifiers used throughout windowKey, counter metrics, and -// retry-after calculation. +// 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 evaluateScope 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. +// 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 tokenBudgetViolation. const ( - metricReq = "req" - metricTok = "tok" - metricCost = "cost" + metricReq = "req" + metricTokIn = "tokin" + metricTokOut = "tokout" + metricCost = "cost" ) // TTLs applied to counter keys. They exceed their window's natural length @@ -46,10 +58,18 @@ const ( // 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 in memoryStore before an opportunistic sweep 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 at +// the new values, and at hourWindowTTL for the new hour window. const ( minWindowTTL = 2 * time.Minute - dayWindowTTL = 25 * time.Hour - monthWindowTTL = 32 * 24 * time.Hour + hourWindowTTL = 48 * time.Hour + dayWindowTTL = 35 * 24 * time.Hour + monthWindowTTL = 400 * 24 * time.Hour ) // usdToMicroFactor scales a USD amount to micro-USD (1e6 micro-USD per @@ -248,16 +268,31 @@ func (u usage) total() int64 { return u.prompt + u.completion } -// limitScope is one entity (a user or their group) 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. +// 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" or "group" + 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. evaluateScope's nil-limits check +// (below) skips it during evaluation, so checkAndCount 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 @@ -479,36 +514,46 @@ func storeDownViolation() *limitViolation { return &limitViolation{message: "limit store unavailable", storeDown: true} } -// 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, so a counter's key -// changes automatically when its window rolls over. -func windowKey(kind, id, metric, window string, t time.Time) string { +// 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() - var bucket string switch window { + case windowHour: + return u.Format("2006010215") case windowMin: - bucket = u.Format("200601021504") + return u.Format("200601021504") case windowDay: - bucket = u.Format("20060102") + return u.Format("20060102") case windowMonth: - bucket = u.Format("200601") + return u.Format("200601") default: - // window is always one of the three constants above, set by this + // 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: windowKey: unknown window %q", window)) + panic(fmt.Sprintf("llmgateway: bucketFor: unknown window %q", window)) } - return fmt.Sprintf("llmgw:%s:%s:%s:%s:%s", 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 fmt.Sprintf("llmgw:%s:%s:%s:%s:%s", kind, id, metric, window, bucketFor(t, window)) } // windowEnd returns the UTC instant at which window's bucket containing t -// ends: the next minute boundary, the next UTC midnight, or the first of -// next month UTC. +// 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: @@ -575,6 +620,14 @@ func (l *limiter) checkAndCount(scopes []limitScope) *limitViolation { return storeDownViolation() } dayCounts[i] = v + + // Hour is stats-only (windowHour's own doc comment): its value is + // never read back here, only written, so the admin usage-history + // API (admin.go) has an hour-granularity req series to read + // later. + if _, ok = l.incrCounter(sc.kind, sc.id, metricReq, windowHour, now, 1, hourWindowTTL); !ok { + return storeDownViolation() + } } for i, sc := range scopes { @@ -607,10 +660,10 @@ func (l *limiter) evaluateScope(sc limitScope, minCount, dayCount int64, now tim if v := requestLimitViolation(sc, "requests-per-day", lim.RequestsPerDay, dayCount, windowDay, now); v != nil { return v } - if v := l.budgetViolation(sc, metricTok, "tokens-per-day", lim.TokensPerDay, windowDay, now); v != nil { + if v := l.tokenBudgetViolation(sc, "tokens-per-day", lim.TokensPerDay, windowDay, now); v != nil { return v } - if v := l.budgetViolation(sc, metricTok, "tokens-per-month", lim.TokensPerMonth, windowMonth, now); v != nil { + if v := l.tokenBudgetViolation(sc, "tokens-per-month", lim.TokensPerMonth, windowMonth, now); v != nil { return v } if v := l.budgetViolation(sc, metricCost, "cost-per-day", usdToMicros(lim.CostPerDayUSD), windowDay, now); v != nil { @@ -657,84 +710,145 @@ func (l *limiter) budgetViolation(sc limitScope, metric, name string, limit int6 } } -// account records u's total tokens and costMicros against every scope's -// day and month counters. A zero-valued metric is skipped entirely — no -// store write for a metric this call has nothing to report. A sample that +// tokenBudgetViolation mirrors budgetViolation for a token limit, which — +// unlike a request or cost limit — enforces a TOTAL budget over two split +// counters (metricTokIn, metricTokOut; v0.2 data-layer task): it reads +// both of the scope's accumulated tok-in/tok-out counters for window in +// ONE storeGetMulti round trip and compares their sum against limit. +// limit<=0 means unlimited. A fail-closed read returns +// storeDownViolation, matching budgetViolation's own contract; name is +// still e.g. "tokens-per-day", so the violation message still says +// "tokens" regardless of which direction pushed the total over. +func (l *limiter) tokenBudgetViolation(sc limitScope, name string, limit int64, window string, now time.Time) *limitViolation { + if limit <= 0 { + return nil + } + keys := []string{ + windowKey(sc.kind, sc.id, metricTokIn, window, now), + windowKey(sc.kind, sc.id, metricTokOut, window, now), + } + vals, ok := l.storeGetMulti(keys) + if !ok || len(vals) != 2 { + return storeDownViolation() + } + used := vals[0] + vals[1] + if used < limit { + return nil + } + return &limitViolation{ + message: fmt.Sprintf("%s %q exceeded %s budget", sc.kind, sc.id, name), + retryAfter: retryAfterSeconds(now, window), + } +} + +// 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. A sample that // hits a fail-closed store error (see storeIncrBy) is dropped silently: // account has no error return to signal it, and dropping — rather than // counting it in the fallback — avoids double-counting once the store // recovers. func (l *limiter) account(scopes []limitScope, u usage, costMicros int64) { now := l.now() - total := u.total() for _, sc := range scopes { - if total != 0 { - l.incrCounter(sc.kind, sc.id, metricTok, windowDay, now, total, dayWindowTTL) - l.incrCounter(sc.kind, sc.id, metricTok, windowMonth, now, total, monthWindowTTL) + if u.prompt != 0 { + l.incrCounter(sc.kind, sc.id, metricTokIn, windowHour, now, u.prompt, hourWindowTTL) + l.incrCounter(sc.kind, sc.id, metricTokIn, windowDay, now, u.prompt, dayWindowTTL) + l.incrCounter(sc.kind, sc.id, metricTokIn, windowMonth, now, u.prompt, monthWindowTTL) + } + if u.completion != 0 { + l.incrCounter(sc.kind, sc.id, metricTokOut, windowHour, now, u.completion, hourWindowTTL) + l.incrCounter(sc.kind, sc.id, metricTokOut, windowDay, now, u.completion, dayWindowTTL) + l.incrCounter(sc.kind, sc.id, metricTokOut, windowMonth, now, u.completion, monthWindowTTL) } if costMicros != 0 { + l.incrCounter(sc.kind, sc.id, metricCost, windowHour, now, costMicros, hourWindowTTL) l.incrCounter(sc.kind, sc.id, metricCost, windowDay, now, costMicros, dayWindowTTL) l.incrCounter(sc.kind, sc.id, metricCost, windowMonth, now, costMicros, monthWindowTTL) } } } -// scopeUsage is one scope's (a user's or a group's) current-window -// counter values — the admin dashboard's usage table (spec §4, v0.2). The -// six value fields mirror LimitsConfig's six limit kinds exactly, so the -// admin API can echo a usage value beside its matching limit. +// 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. +const usageKeysPerScope = 8 + +// 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 (tokenBudgetViolation) sums the two itself. type scopeUsage struct { - kind string // "user" or "group", mirroring limitScope.kind + kind string // "user", "group", or "total", mirroring limitScope.kind id string requestsPerMinute int64 requestsPerDay int64 - tokensPerDay int64 - tokensPerMonth int64 + tokensInPerDay int64 + tokensInPerMonth int64 + tokensOutPerDay int64 + tokensOutPerMonth int64 costPerDayMicros int64 costPerMonthMicros int64 - // storeDown reports whether reading any of the six 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 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 six windowKey strings currentUsage reads -// for sc at time now, in a fixed order — req/min, req/day, tok/day, -// tok/month, cost/day, cost/month — matching scopeUsage's field order -// exactly, so currentUsage can map storeGetMulti's result slice back to -// named fields by plain index. +// 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 — matching scopeUsage's field order exactly, so currentUsage +// can map storeGetMulti's result slice back to named fields by plain +// index. 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, metricTok, windowDay, now), - windowKey(sc.kind, sc.id, metricTok, windowMonth, 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), } } -// currentUsage reads every scope's six 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. +// 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. // // 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 six -// keys are flattened into a single round trip against the shared store, +// 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 6-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 and -// groups into one scopes slice before calling in, then slicing the flat -// result back into its two response sections at the same split point. +// 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 @@ -751,7 +865,7 @@ func (l *limiter) currentUsage(scopes []limitScope) []scopeUsage { } now := l.now() - allKeys := make([]string, 0, len(scopes)*6) + allKeys := make([]string, 0, len(scopes)*usageKeysPerScope) for _, sc := range scopes { allKeys = append(allKeys, usageWindowKeys(sc, now)...) } @@ -771,13 +885,86 @@ func (l *limiter) currentUsage(scopes []limitScope) []scopeUsage { } for i, sc := range scopes { - v := vals[i*6 : i*6+6] + v := vals[i*usageKeysPerScope : i*usageKeysPerScope+usageKeysPerScope] out[i] = scopeUsage{ kind: sc.kind, id: sc.id, requestsPerMinute: v[0], requestsPerDay: v[1], - tokensPerDay: v[2], tokensPerMonth: v[3], - costPerDayMicros: v[4], costPerMonthMicros: v[5], + tokensInPerDay: v[2], tokensInPerMonth: v[3], + tokensOutPerDay: v[4], tokensOutPerMonth: v[5], + costPerDayMicros: v[6], costPerMonthMicros: v[7], } } 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 +} + +// 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)) + } +} + +// 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. +func historyBucketKeys(kind, id, metric, window string, now time.Time, span int) (keys, buckets []string) { + keys = make([]string, span) + buckets = make([]string, span) + for i := 0; i < span; i++ { + t := historyStepBack(now, window, span-1-i) + keys[i] = windowKey(kind, id, metric, window, t) + buckets[i] = bucketFor(t, window) + } + return keys, buckets +} + +// 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): 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) { + return nil, false + } + points := make([]historyPoint, span) + for i := range points { + points[i] = historyPoint{bucket: buckets[i], value: vals[i]} + } + return points, true +} diff --git a/limits_test.go b/limits_test.go index ec6c37e..0da9486 100644 --- a/limits_test.go +++ b/limits_test.go @@ -168,13 +168,15 @@ func TestLimiterRequestLimitRetryAfter(t *testing.T) { // TestWindowKeyFormat pins the exact key layout every store operation // depends on: llmgw:{kind}:{id}:{metric}:{window}:{bucket}, with bucket -// formatted from t.UTC() at each window's granularity. +// formatted from t.UTC() at each window's granularity — including hour +// (v0.2 data-layer task). func TestWindowKeyFormat(t *testing.T) { tm := time.Date(2026, 8, 20, 10, 4, 59, 0, time.FixedZone("UTC+1", 3600)) // 09:04:59 UTC cases := []struct { window string want string }{ + {windowHour, "llmgw:user:a:req:hour:2026082009"}, {windowMin, "llmgw:user:a:req:min:202608200904"}, {windowDay, "llmgw:user:a:req:day:20260820"}, {windowMonth, "llmgw:user:a:req:month:202608"}, @@ -186,6 +188,270 @@ func TestWindowKeyFormat(t *testing.T) { } } +// TestWindowKeyFormat_HourUTCBoundary pins the hour bucket rolling over +// exactly at the UTC hour boundary, and proves a non-UTC input is +// converted to UTC first (an input at 00:04:59 in a UTC+1 zone is 23:04:59 +// UTC the PREVIOUS day — a bug converting the hour field alone without +// re-deriving the date would produce "...23" glued onto the wrong day). +func TestWindowKeyFormat_HourUTCBoundary(t *testing.T) { + justBefore := time.Date(2026, 8, 20, 8, 59, 59, 0, time.UTC) + justAfter := time.Date(2026, 8, 20, 9, 0, 0, 0, time.UTC) + if got := windowKey("user", "a", "req", windowHour, justBefore); got != "llmgw:user:a:req:hour:2026082008" { + t.Errorf("just before the hour boundary: got %q", got) + } + if got := windowKey("user", "a", "req", windowHour, justAfter); got != "llmgw:user:a:req:hour:2026082009" { + t.Errorf("just after the hour boundary: got %q", got) + } + + // 00:04:59 in UTC+1 is 23:04:59 UTC the previous day. + crossMidnight := time.Date(2026, 8, 21, 0, 4, 59, 0, time.FixedZone("UTC+1", 3600)) + if got := windowKey("user", "a", "req", windowHour, crossMidnight); got != "llmgw:user:a:req:hour:2026082023" { + t.Errorf("cross-midnight non-UTC input: got %q, want the previous UTC day's hour 23 bucket", got) + } +} + +// TestWindowEnd_HourBoundary asserts windowEnd's new hour case (v0.2 +// data-layer task) returns the next UTC hour boundary, and that a value +// exactly on the boundary reports zero seconds remaining in that boundary +// (retryAfterSeconds rounds a zero duration up to 0, distinct from +// rounding a fractional second up to 1 — see TestLimiterRetryAfter for +// the day/month/min cases this mirrors). +func TestWindowEnd_HourBoundary(t *testing.T) { + t.Run("mid-hour", func(t *testing.T) { + got := windowEnd(time.Date(2026, 8, 20, 9, 30, 0, 0, time.UTC), windowHour) + want := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Errorf("windowEnd = %v, want %v", got, want) + } + }) + t.Run("exactly on the boundary", func(t *testing.T) { + got := windowEnd(time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC), windowHour) + want := time.Date(2026, 8, 20, 11, 0, 0, 0, time.UTC) + if !got.Equal(want) { + t.Errorf("windowEnd = %v, want %v", got, want) + } + }) +} + +// TestHistoryStepBack_HourAndDay asserts the fixed-duration step-back +// cases (hour, day) land i units before now exactly, including across a +// UTC day boundary. +func TestHistoryStepBack_HourAndDay(t *testing.T) { + now := time.Date(2026, 8, 20, 1, 30, 0, 0, time.UTC) + + if got := historyStepBack(now, windowHour, 0); !got.Equal(now) { + t.Errorf("i=0 must be now itself, got %v", got) + } + wantHour := time.Date(2026, 8, 19, 23, 30, 0, 0, time.UTC) // 2 hours back, crossing midnight + if got := historyStepBack(now, windowHour, 2); !got.Equal(wantHour) { + t.Errorf("i=2 hours back = %v, want %v", got, wantHour) + } + + wantDay := time.Date(2026, 8, 17, 1, 30, 0, 0, time.UTC) + if got := historyStepBack(now, windowDay, 3); !got.Equal(wantDay) { + t.Errorf("i=3 days back = %v, want %v", got, wantDay) + } +} + +// TestHistoryStepBack_MonthAnchorsOnDayOne is the month-arithmetic trap +// TestLimiterRetryAfter's own month case does not exercise: stepping back +// from a high day-of-month (the 31st) with a naive +// now.AddDate(0, -i, 0) overflows a shorter target month (e.g. "Mar 31" +// minus one month normalizes to "Mar 3", not "Feb 28") and would land in +// the WRONG calendar month's bucket. historyStepBack avoids this by +// anchoring on day 1 first — this test drives it from Mar 31 and Jan 31 +// specifically because those are the inputs that would expose a +// regression back to the naive form. +func TestHistoryStepBack_MonthAnchorsOnDayOne(t *testing.T) { + cases := []struct { + now time.Time + name string + want string + i int + }{ + {name: "Mar 31 minus 1 month = Feb", now: time.Date(2026, 3, 31, 12, 0, 0, 0, time.UTC), i: 1, want: "202602"}, + {name: "Jan 31 minus 1 month = Dec (year rollover)", now: time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC), i: 1, want: "202512"}, + {name: "May 31 minus 3 months = Feb", now: time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC), i: 3, want: "202602"}, + {name: "i=0 is now's own month", now: time.Date(2026, 3, 31, 0, 0, 0, 0, time.UTC), i: 0, want: "202603"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := bucketFor(historyStepBack(c.now, windowMonth, c.i), windowMonth) + if got != c.want { + t.Errorf("bucketFor(historyStepBack(%v, month, %d)) = %q, want %q", c.now, c.i, got, c.want) + } + }) + } +} + +// TestHistoryBucketKeys_OldestFirstInclusiveOfCurrent asserts +// historyBucketKeys returns span buckets oldest-first, with the LAST +// entry being now's own (current, possibly partial) bucket — the +// "inclusive of current bucket" contract GET /admin/api/usage/history +// promises. +func TestHistoryBucketKeys_OldestFirstInclusiveOfCurrent(t *testing.T) { + now := time.Date(2026, 8, 20, 14, 0, 0, 0, time.UTC) + keys, buckets := historyBucketKeys("total", "all", "req", windowHour, now, 4) + + wantBuckets := []string{"2026082011", "2026082012", "2026082013", "2026082014"} + if len(buckets) != len(wantBuckets) { + t.Fatalf("len(buckets) = %d, want %d", len(buckets), len(wantBuckets)) + } + for i, want := range wantBuckets { + if buckets[i] != want { + t.Errorf("buckets[%d] = %q, want %q", i, buckets[i], want) + } + wantKey := "llmgw:total:all:req:hour:" + want + if keys[i] != wantKey { + t.Errorf("keys[%d] = %q, want %q", i, keys[i], wantKey) + } + } +} + +// TestLimiterHistory_OneBatchCallOldestFirst drives limiter.history +// against a counting stub store: it must issue exactly ONE getMulti call +// for the whole span, and the returned points must be oldest-first with +// the seeded values in the right buckets. +func TestLimiterHistory_OneBatchCallOldestFirst(t *testing.T) { + now := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + countStore := &historyCountingStore{values: map[string]int64{ + windowKey("total", "all", metricReq, windowHour, now.Add(-2*time.Hour)): 5, + windowKey("total", "all", metricReq, windowHour, now): 9, + }} + l := newLimiter(countStore, true) + l.nowFn = func() time.Time { return now } + + points, ok := l.history("total", "all", "req", windowHour, now, 3) + if !ok { + t.Fatal("want ok=true") + } + if countStore.getMultiCalls != 1 { + t.Errorf("getMultiCalls = %d, want 1", countStore.getMultiCalls) + } + if len(points) != 3 { + t.Fatalf("len(points) = %d, want 3", len(points)) + } + if points[0].value != 5 || points[0].bucket != bucketFor(now.Add(-2*time.Hour), windowHour) { + t.Errorf("points[0] (oldest) = %+v, want value 5 at the -2h bucket", points[0]) + } + if points[1].value != 0 { + t.Errorf("points[1] (unseeded -1h bucket) = %+v, want value 0", points[1]) + } + if points[2].value != 9 || points[2].bucket != bucketFor(now, windowHour) { + t.Errorf("points[2] (newest, current bucket) = %+v, want value 9", points[2]) + } +} + +// historyCountingStore is a counterStore stub whose getMulti serves fixed +// values and counts its own calls — mirrors admin_test.go's +// countingMultiStore, defined separately here since limits_test.go must +// not depend on admin_test.go's test-only types. +type historyCountingStore struct { + values map[string]int64 + getMultiCalls int +} + +func (s *historyCountingStore) incrBy(string, int64, time.Duration) (int64, error) { return 0, nil } +func (s *historyCountingStore) get(string) (int64, error) { return 0, nil } +func (s *historyCountingStore) getMulti(keys []string) ([]int64, error) { + s.getMultiCalls++ + out := make([]int64, len(keys)) + for i, k := range keys { + out[i] = s.values[k] + } + return out, nil +} + +// TestLimiterHistory_StoreDown asserts limiter.history reports ok=false +// when the configured store errors and failOpen is false — the caller +// (GET /admin/api/usage/history) must answer 503, never a silently-zero +// series. +func TestLimiterHistory_StoreDown(t *testing.T) { + l := newLimiter(alwaysErrStore{}, false) + points, ok := l.history("total", "all", "req", windowDay, time.Now(), 5) + if ok { + t.Fatal("want ok=false when the configured store errors and failOpen is false") + } + if points != nil { + t.Errorf("points = %+v, want nil on a storeDown read", points) + } +} + +// TestWithTotalScope_AppendsUnlimitedTotalScope asserts withTotalScope +// appends exactly one {kind: totalScopeKind, id: totalScopeID, limits: +// nil} scope after whatever buildLimitScopes already returned. +func TestWithTotalScope_AppendsUnlimitedTotalScope(t *testing.T) { + base := []limitScope{{kind: "user", id: "u", limits: &LimitsConfig{RequestsPerMinute: 1}}} + got := withTotalScope(base) + + if len(got) != 2 { + t.Fatalf("len(got) = %d, want 2", len(got)) + } + if got[0].kind != "user" || got[0].id != "u" { + t.Errorf("got[0] = %+v, want the original user scope untouched", got[0]) + } + total := got[1] + if total.kind != totalScopeKind || total.id != totalScopeID || total.limits != nil { + t.Errorf("got[1] = %+v, want kind=%q id=%q limits=nil", total, totalScopeKind, totalScopeID) + } +} + +// TestWithTotalScope_CountedButNeverEvaluated drives checkAndCount many +// times against a scopes slice built via withTotalScope: the total +// scope's own limits are always nil, so no volume of traffic can ever +// make it violate — evaluateScope's nil-limits check skips it — while its +// req counter still climbs by exactly one per call, proving +// checkAndCount's counting loop does not skip a nil-limits scope the way +// evaluation does. +func TestWithTotalScope_CountedButNeverEvaluated(t *testing.T) { + l := newLimiter(nil, true) + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + l.nowFn = func() time.Time { return now } + + scopes := withTotalScope([]limitScope{{kind: "user", id: "u", limits: nil}}) + const n = 250 + for i := 0; i < n; i++ { + if v := l.checkAndCount(scopes); v != nil { + t.Fatalf("iteration %d: total scope must never violate (limits always nil), got %+v", i, v) + } + } + + total, ok := l.getCounter(totalScopeKind, totalScopeID, metricReq, windowDay, now) + if !ok || total != n { + t.Errorf("total req:day = %d, ok=%v, want %d (counted every call despite never evaluating)", total, ok, n) + } + totalHour, ok := l.getCounter(totalScopeKind, totalScopeID, metricReq, windowHour, now) + if !ok || totalHour != n { + t.Errorf("total req:hour = %d, ok=%v, want %d", totalHour, ok, n) + } +} + +// TestWithTotalScope_AccountWritesHourBuckets asserts account (v0.2 +// data-layer task) writes the total scope's hour-window tokin/tokout/cost +// counters, not just day/month — checkAndCount's own hour write is +// covered by TestWithTotalScope_CountedButNeverEvaluated above. +func TestWithTotalScope_AccountWritesHourBuckets(t *testing.T) { + l := newLimiter(nil, true) + now := time.Date(2026, 8, 20, 12, 0, 0, 0, time.UTC) + l.nowFn = func() time.Time { return now } + + scopes := withTotalScope([]limitScope{{kind: "user", id: "u", limits: nil}}) + l.account(scopes, usage{prompt: 4, completion: 6}, 700) + + tokIn, ok := l.getCounter(totalScopeKind, totalScopeID, metricTokIn, windowHour, now) + if !ok || tokIn != 4 { + t.Errorf("total tokin:hour = %d, ok=%v, want 4", tokIn, ok) + } + tokOut, ok := l.getCounter(totalScopeKind, totalScopeID, metricTokOut, windowHour, now) + if !ok || tokOut != 6 { + t.Errorf("total tokout:hour = %d, ok=%v, want 6", tokOut, ok) + } + cost, ok := l.getCounter(totalScopeKind, totalScopeID, metricCost, windowHour, now) + if !ok || cost != 700 { + t.Errorf("total cost:hour = %d, ok=%v, want 700", cost, ok) + } +} + // TestMemoryStoreIncrAndGet exercises the fallback store directly: fresh // keys start at n, repeated incrBy accumulates, and get on an unknown key // returns 0 with no error. @@ -303,9 +569,10 @@ func TestLimiterCostBudgetRoundsUSDConversion(t *testing.T) { } } -// TestAccountSkipsZeroMetrics asserts account writes no tokens counter -// when usage totals 0, and no cost counter when costMicros is 0 — each -// metric's absence is independently observable via get. +// TestAccountSkipsZeroMetrics asserts account writes no tokin/tokout +// counter when the matching usage direction is 0, and no cost counter +// when costMicros is 0 — each metric's absence is independently +// observable via get. func TestAccountSkipsZeroMetrics(t *testing.T) { now := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) l := newLimiter(nil, true) @@ -314,9 +581,13 @@ func TestAccountSkipsZeroMetrics(t *testing.T) { l.account(scopes, usage{}, 0) - tok, ok := l.getCounter("user", "z", metricTok, windowDay, now) - if !ok || tok != 0 { - t.Errorf("tok counter = %d, ok=%v, want 0, true", tok, ok) + tokIn, ok := l.getCounter("user", "z", metricTokIn, windowDay, now) + if !ok || tokIn != 0 { + t.Errorf("tokin counter = %d, ok=%v, want 0, true", tokIn, ok) + } + tokOut, ok := l.getCounter("user", "z", metricTokOut, windowDay, now) + if !ok || tokOut != 0 { + t.Errorf("tokout counter = %d, ok=%v, want 0, true", tokOut, ok) } cost, ok := l.getCounter("user", "z", metricCost, windowDay, now) if !ok || cost != 0 { @@ -324,6 +595,54 @@ func TestAccountSkipsZeroMetrics(t *testing.T) { } } +// TestAccountSplitsTokensIndependently asserts account writes prompt +// tokens to metricTokIn and completion tokens to metricTokOut +// independently — including the asymmetric case (prompt only, no +// completion yet) the estimation fallback and a dropped stream both rely +// on: a nonzero prompt with a zero completion must still write tokin +// alone, never skip it because completion is 0. +func TestAccountSplitsTokensIndependently(t *testing.T) { + now := time.Date(2026, 8, 20, 10, 0, 0, 0, time.UTC) + l := newLimiter(nil, true) + l.nowFn = func() time.Time { return now } + scopes := []limitScope{{kind: "user", id: "split", limits: nil}} + + l.account(scopes, usage{prompt: 25}, 0) // completion left at zero + + tokIn, ok := l.getCounter("user", "split", metricTokIn, windowDay, now) + if !ok || tokIn != 25 { + t.Errorf("tokin counter = %d, ok=%v, want 25, true", tokIn, ok) + } + tokOut, ok := l.getCounter("user", "split", metricTokOut, windowDay, now) + if !ok || tokOut != 0 { + t.Errorf("tokout counter = %d, ok=%v, want 0, true (no completion tokens reported)", tokOut, ok) + } + + l.account(scopes, usage{completion: 9}, 0) // prompt left at zero this time + tokIn, ok = l.getCounter("user", "split", metricTokIn, windowDay, now) + if !ok || tokIn != 25 { + t.Errorf("tokin counter = %d, ok=%v, want still 25 (this call reported no prompt tokens)", tokIn, ok) + } + tokOut, ok = l.getCounter("user", "split", metricTokOut, windowDay, now) + if !ok || tokOut != 9 { + t.Errorf("tokout counter = %d, ok=%v, want 9", tokOut, ok) + } +} + +// TestTokenBudgetSumsInAndOut asserts a TokensPerDay/TokensPerMonth limit +// enforces a TOTAL budget across metricTokIn and metricTokOut combined — +// the operator directive's "limits stay total-token (limiter sums both at +// read)": neither direction alone crosses the budget, but their sum does. +func TestTokenBudgetSumsInAndOut(t *testing.T) { + l := newLimiter(nil, true) + scopes := []limitScope{{kind: "group", id: "g", limits: &LimitsConfig{TokensPerDay: 100}}} + + l.account(scopes, usage{prompt: 60, completion: 45}, 0) // 105 > 100, split 60/45 + if v := l.checkAndCount(scopes); v == nil { + t.Fatal("60 tokin + 45 tokout = 105 > 100 must refuse, even though neither direction alone exceeds 100") + } +} + // TestLimiterRequestPerDayLimit is carried-item (b): a per-day request // limit blocks once the day's count exceeds it, independent of the // (unset, unlimited) per-minute limit. @@ -491,12 +810,19 @@ func TestLimiter_FailClosed_AccountDropsSampleOnStoreError(t *testing.T) { l.account(scopes, usage{prompt: 10, completion: 10}, 500) - got, err := l.fallback.get(windowKey("user", "u", metricTok, windowDay, l.now())) + gotIn, err := l.fallback.get(windowKey("user", "u", metricTokIn, windowDay, l.now())) if err != nil { t.Fatalf("fallback.get: %v", err) } - if got != 0 { - t.Errorf("fallback tok:day counter = %d, want 0 (sample must be dropped, not counted locally)", got) + if gotIn != 0 { + t.Errorf("fallback tokin:day counter = %d, want 0 (sample must be dropped, not counted locally)", gotIn) + } + gotOut, err := l.fallback.get(windowKey("user", "u", metricTokOut, windowDay, l.now())) + if err != nil { + t.Fatalf("fallback.get: %v", err) + } + if gotOut != 0 { + t.Errorf("fallback tokout:day counter = %d, want 0 (sample must be dropped, not counted locally)", gotOut) } } diff --git a/mcp_a2a.go b/mcp_a2a.go index 759ec96..e854867 100644 --- a/mcp_a2a.go +++ b/mcp_a2a.go @@ -166,7 +166,7 @@ func (g *Gateway) handleTargetProxy(w http.ResponseWriter, r *http.Request, u *u return } - scopes := buildLimitScopes(u, grp) + scopes := withTotalScope(buildLimitScopes(u, grp)) if violation := g.limiter.checkAndCount(scopes); violation != nil { writeLimitViolation(w, violation) return diff --git a/mcp_a2a_test.go b/mcp_a2a_test.go index e38f25d..be8721a 100644 --- a/mcp_a2a_test.go +++ b/mcp_a2a_test.go @@ -660,9 +660,13 @@ func TestHandleTargetProxy_JSONResponse_OnlyRequestCounterMoves(t *testing.T) { t.Errorf("body = %q, want verbatim upstream body", rec.Body.String()) } - tok, ok := gw.limiter.getCounter("user", "alice", metricTok, windowDay, time.Now()) - if !ok || tok != 0 { - t.Errorf("user token/day counter = %d (ok=%v), want 0 — target proxy must never account response usage", tok, ok) + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 0 { + t.Errorf("user tokin/day counter = %d (ok=%v), want 0 — target proxy must never account response usage", tokIn, ok) + } + tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) + if !ok || tokOut != 0 { + t.Errorf("user tokout/day counter = %d (ok=%v), want 0 — target proxy must never account response usage", tokOut, ok) } cost, ok := gw.limiter.getCounter("user", "alice", metricCost, windowDay, time.Now()) if !ok || cost != 0 { @@ -672,6 +676,14 @@ func TestHandleTargetProxy_JSONResponse_OnlyRequestCounterMoves(t *testing.T) { if !ok || reqCount != 1 { t.Errorf("user request/min counter = %d (ok=%v), want 1 — the request itself is still accounted", reqCount, ok) } + + // withTotalScope (handleTargetProxy, mcp_a2a.go) must have appended + // the synthetic total scope too (v0.2 data-layer task): counted like + // any other scope, still never token/cost-accounted here. + totalReq, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricReq, windowMin, time.Now()) + if !ok || totalReq != 1 { + t.Errorf("total request/min counter = %d (ok=%v), want 1", totalReq, ok) + } } // --- config validation at construction --- diff --git a/routes_media.go b/routes_media.go index 7cdd370..eafdeb0 100644 --- a/routes_media.go +++ b/routes_media.go @@ -69,7 +69,7 @@ func (g *Gateway) resolveMediaRequest(sw *statusTrackingWriter, u *user, grp *gr writeModelResolveError(sw, err) return nil, "", false } - if violation := g.limiter.checkAndCount(buildLimitScopes(u, grp)); violation != nil { + if violation := g.limiter.checkAndCount(withTotalScope(buildLimitScopes(u, grp))); violation != nil { writeLimitViolation(sw, violation) return nil, "", false } diff --git a/routes_media_test.go b/routes_media_test.go index ef02098..8677a7c 100644 --- a/routes_media_test.go +++ b/routes_media_test.go @@ -164,8 +164,18 @@ func TestHandleImagesGenerations_OpenAI_HappyPath_NativeForward(t *testing.T) { if !ok || reqCount != 1 { t.Errorf("user request/min counter = %d (ok=%v), want 1", reqCount, ok) } - if tok, ok := gw.limiter.getCounter("user", "alice", metricTok, windowDay, time.Now()); ok && tok != 0 { - t.Errorf("user token/day counter = %d, want 0 (images are never token-accounted)", tok) + if tokIn, okIn := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()); okIn && tokIn != 0 { + t.Errorf("user tokin/day counter = %d, want 0 (images are never token-accounted)", tokIn) + } + if tokOut, okOut := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()); okOut && tokOut != 0 { + t.Errorf("user tokout/day counter = %d, want 0 (images are never token-accounted)", tokOut) + } + + // withTotalScope (resolveMediaRequest, routes_media.go) must have + // appended the synthetic total scope too (v0.2 data-layer task). + totalReq, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricReq, windowMin, time.Now()) + if !ok || totalReq != 1 { + t.Errorf("total request/min counter = %d (ok=%v), want 1", totalReq, ok) } } diff --git a/routes_passthrough.go b/routes_passthrough.go index bf941a0..ef149a4 100644 --- a/routes_passthrough.go +++ b/routes_passthrough.go @@ -266,7 +266,7 @@ func (g *Gateway) handlePassthrough(w http.ResponseWriter, r *http.Request, u *u return } - scopes := buildLimitScopes(u, grp) + scopes := withTotalScope(buildLimitScopes(u, grp)) if violation := g.limiter.checkAndCount(scopes); violation != nil { writeLimitViolation(w, violation) return diff --git a/routes_passthrough_test.go b/routes_passthrough_test.go index d40b348..04c570a 100644 --- a/routes_passthrough_test.go +++ b/routes_passthrough_test.go @@ -461,9 +461,24 @@ func TestHandlePassthrough_NonStreamJSON_AccountsUsage(t *testing.T) { t.Errorf("body = %q, want verbatim upstream body %q", rec.Body.String(), respBody) } - tok, ok := gw.limiter.getCounter("user", "alice", metricTok, windowDay, time.Now()) - if !ok || tok != 10 { - t.Errorf("user token/day counter = %d (ok=%v), want 10", tok, ok) + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 7 { + t.Errorf("user tokin/day counter = %d (ok=%v), want 7", tokIn, ok) + } + tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) + if !ok || tokOut != 3 { + t.Errorf("user tokout/day counter = %d (ok=%v), want 3", tokOut, ok) + } + + // withTotalScope (handlePassthrough, routes_passthrough.go) must have + // appended the synthetic total scope too (v0.2 data-layer task). + totalTokIn, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricTokIn, windowDay, time.Now()) + if !ok || totalTokIn != 7 { + t.Errorf("total tokin/day counter = %d (ok=%v), want 7", totalTokIn, ok) + } + totalTokOut, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricTokOut, windowDay, time.Now()) + if !ok || totalTokOut != 3 { + t.Errorf("total tokout/day counter = %d (ok=%v), want 3", totalTokOut, ok) } } @@ -623,9 +638,13 @@ func TestHandlePassthrough_AcceptEncodingStripped_AccountsUsageThroughGzip(t *te t.Errorf("body = %q, want decompressed upstream body %q", rec.Body.String(), respBody) } - tok, ok := gw.limiter.getCounter("user", "alice", metricTok, windowDay, time.Now()) - if !ok || tok != 10 { - t.Errorf("user token/day counter = %d (ok=%v), want 10 — usage must still be accounted through gzip", tok, ok) + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 7 { + t.Errorf("user tokin/day counter = %d (ok=%v), want 7 — usage must still be accounted through gzip", tokIn, ok) + } + tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) + if !ok || tokOut != 3 { + t.Errorf("user tokout/day counter = %d (ok=%v), want 3 — usage must still be accounted through gzip", tokOut, ok) } } @@ -757,9 +776,13 @@ func TestHandlePassthrough_OversizedJSONBody_ClientGetsFullBody_RequestOnlyAccou t.Errorf("client body = %d bytes, want the full %d bytes (client copy must never truncate)", rec.Body.Len(), len(respBody)) } - tok, ok := gw.limiter.getCounter("user", "alice", metricTok, windowDay, time.Now()) - if !ok || tok != 0 { - t.Errorf("user token/day counter = %d (ok=%v), want 0 — usage accounting must be skipped over the tee cap", tok, ok) + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 0 { + t.Errorf("user tokin/day counter = %d (ok=%v), want 0 — usage accounting must be skipped over the tee cap", tokIn, ok) + } + tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) + if !ok || tokOut != 0 { + t.Errorf("user tokout/day counter = %d (ok=%v), want 0 — usage accounting must be skipped over the tee cap", tokOut, ok) } reqCount, ok := gw.limiter.getCounter("user", "alice", metricReq, windowMin, time.Now()) if !ok || reqCount != 1 { diff --git a/routes_unified.go b/routes_unified.go index bb8ac08..95b8b38 100644 --- a/routes_unified.go +++ b/routes_unified.go @@ -113,7 +113,7 @@ func (g *Gateway) runUnified(w http.ResponseWriter, r *http.Request, u *user, gr return } - scopes := buildLimitScopes(u, grp) + scopes := withTotalScope(buildLimitScopes(u, grp)) if violation := g.limiter.checkAndCount(scopes); violation != nil { writeLimitViolation(sw, violation) return @@ -225,6 +225,12 @@ func (g *Gateway) runUnified(w http.ResponseWriter, r *http.Request, u *user, gr // 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. +// +// Callers metering actual LLM traffic wrap this result in withTotalScope +// before passing it to checkAndCount/account; handleAdminAPI (admin.go) +// calls this directly, without withTotalScope, so an admin request's own +// 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 { @@ -236,6 +242,20 @@ func buildLimitScopes(u *user, grp *group) []limitScope { return scopes } +// withTotalScope returns scopes with the synthetic total scope +// (totalScopeKind/totalScopeID, limits.go) appended — the single helper +// every metered route (runUnified above; resolveMediaRequest, +// routes_media.go; handlePassthrough, routes_passthrough.go; +// handleTargetProxy, mcp_a2a.go) calls around its own buildLimitScopes +// result, so none of them can forget it and none of them duplicate the +// scope literal. Deliberately not folded into buildLimitScopes itself: +// that helper is also called directly by handleAdminAPI (admin.go) for +// its own req/min-req/day accounting, and admin traffic must never +// contribute to the total LLM-traffic scope. +func withTotalScope(scopes []limitScope) []limitScope { + return append(scopes, limitScope{kind: totalScopeKind, id: totalScopeID, limits: nil}) +} + // unifiedCostMicros resolves the price to charge one request's usage // against: canonical ("provider/model") first, falling back to bare — the // upstream model id alone — only when canonical has no configured price at diff --git a/routes_unified_test.go b/routes_unified_test.go index c2c6c58..bb6b45d 100644 --- a/routes_unified_test.go +++ b/routes_unified_test.go @@ -127,14 +127,34 @@ func TestHandleChat_HappyPath_NonStreaming_AccountsUsage(t *testing.T) { t.Errorf("body = %q, want verbatim upstream body %q", rec.Body.String(), respBody) } - tok, ok := gw.limiter.getCounter("user", "alice", metricTok, windowDay, time.Now()) - if !ok || tok != 15 { - t.Errorf("user token/day counter = %d (ok=%v), want 15", tok, ok) + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 10 { + t.Errorf("user tokin/day counter = %d (ok=%v), want 10", tokIn, ok) + } + tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) + if !ok || tokOut != 5 { + t.Errorf("user tokout/day counter = %d (ok=%v), want 5", tokOut, ok) } reqCount, ok := gw.limiter.getCounter("user", "alice", metricReq, windowMin, time.Now()) if !ok || reqCount != 1 { t.Errorf("user request/min counter = %d (ok=%v), want 1", reqCount, ok) } + + // withTotalScope (routes_unified.go) must have appended the synthetic + // total scope to runUnified's own checkAndCount/account calls: its + // req/tokin/tokout counters mirror alice's own (v0.2 data-layer task). + totalReq, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricReq, windowMin, time.Now()) + if !ok || totalReq != 1 { + t.Errorf("total request/min counter = %d (ok=%v), want 1", totalReq, ok) + } + totalTokIn, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricTokIn, windowDay, time.Now()) + if !ok || totalTokIn != 10 { + t.Errorf("total tokin/day counter = %d (ok=%v), want 10", totalTokIn, ok) + } + totalTokOut, ok := gw.limiter.getCounter(totalScopeKind, totalScopeID, metricTokOut, windowDay, time.Now()) + if !ok || totalTokOut != 5 { + t.Errorf("total tokout/day counter = %d (ok=%v), want 5", totalTokOut, ok) + } } // TestHandleChat_Streaming_FlushesIncrementally proves a streaming chat @@ -980,9 +1000,13 @@ func TestHandleChat_MidStreamDropAfterUsageChunk_AccountsPartialUsage(t *testing t.Errorf("want the mid-stream error logged, got %q", logBuf.String()) } - tok, ok := gw.limiter.getCounter("user", "alice", metricTok, windowDay, time.Now()) - if !ok || tok != 10 { - t.Errorf("user token/day counter = %d (ok=%v), want 10 (the usage chunk captured before the drop, 7 prompt + 3 completion)", tok, ok) + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 7 { + t.Errorf("user tokin/day counter = %d (ok=%v), want 7 (the usage chunk captured before the drop)", tokIn, ok) + } + tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) + if !ok || tokOut != 3 { + t.Errorf("user tokout/day counter = %d (ok=%v), want 3 (the usage chunk captured before the drop)", tokOut, ok) } } @@ -1183,9 +1207,13 @@ func TestHandleChat_CacheHitMiss_EndToEnd_CountersAndHeaders(t *testing.T) { if !ok || reqCount != 1 { t.Errorf("req:day after first request = %d (ok=%v), want 1", reqCount, ok) } - tok, ok := gw.limiter.getCounter("user", "alice", metricTok, windowDay, time.Now()) - if !ok || tok != 15 { - t.Errorf("tok:day after first request = %d (ok=%v), want 15 (10 prompt + 5 completion)", tok, ok) + tokIn, ok := gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 10 { + t.Errorf("tokin:day after first request = %d (ok=%v), want 10", tokIn, ok) + } + tokOut, ok := gw.limiter.getCounter("user", "alice", metricTokOut, windowDay, time.Now()) + if !ok || tokOut != 5 { + t.Errorf("tokout:day after first request = %d (ok=%v), want 5", tokOut, ok) } // Second, identical request: hit. @@ -1207,9 +1235,9 @@ func TestHandleChat_CacheHitMiss_EndToEnd_CountersAndHeaders(t *testing.T) { if !ok || reqCount != 2 { t.Errorf("req:day after the cache hit = %d (ok=%v), want 2 (hit still counts as one more request)", reqCount, ok) } - tok, ok = gw.limiter.getCounter("user", "alice", metricTok, windowDay, time.Now()) - if !ok || tok != 15 { - t.Errorf("tok:day after the cache hit = %d (ok=%v), want still 15 (a hit accounts zero tokens, no estimation)", tok, ok) + tokIn, ok = gw.limiter.getCounter("user", "alice", metricTokIn, windowDay, time.Now()) + if !ok || tokIn != 10 { + t.Errorf("tokin:day after the cache hit = %d (ok=%v), want still 10 (a hit accounts zero tokens, no estimation)", tokIn, ok) } }