Data layer for the upcoming Vue admin charts (v0.2 data-layer task).
Tokens-in/out split (limits.go): metricTok replaced by metricTokIn
("tokin") and metricTokOut ("tokout"). account() records usage.prompt
and usage.completion independently, skipping a direction only when its
own value is zero. A TokensPerDay/TokensPerMonth limit still enforces
one TOTAL budget: tokenBudgetViolation reads both counters in one
storeGetMulti batch and sums them; the violation message still says
"tokens". Estimated usage (the request-body-size fallback) lands
entirely in tokin, documented at its call site.
Hour window (limits.go): windowHour ("hour"), bucket format
"2006010215" UTC, hourWindowTTL (48h). checkAndCount/account both write
it on every metered route; no LimitsConfig field ever names it, so
evaluateScope never evaluates it. dayWindowTTL/monthWindowTTL bumped to
35d/400d so the usage-history API's largest span always finds a live
key.
Total scope (limits.go, routes_unified.go): a synthetic
{kind:"total", id:"all"} scope, limits always nil. withTotalScope
wraps buildLimitScopes' result at every metered route (unified,
media, passthrough, mcp/a2a) so none can forget it; handleAdminAPI's
own accounting stays on buildLimitScopes alone, so admin traffic never
contributes to it.
History API (admin.go): GET /admin/api/usage/history?scope=&metric=&
window=&span= — admin-gated like the other two JSON routes, but
skips checkAndCount ahead of that same change landing for every
/admin/api/* route in a later task. Validates scope kind/metric/window
(400), span bounds per window (400), and unknown user/group id (404).
Computes every bucket key up front and reads them in ONE storeGetMulti
batch; a storeDown read is 503, never a silently-zero series. Works
against memoryStore too.
Admin usage API (admin.go): scopeUsage/adminUsageEntryView now expose
tokensInPerDay/tokensOutPerDay and their per-month counterparts in
place of the combined tokensPerDay/tokensPerMonth pair; buildAdminUsage
adds a "total" row to the response, still one storeGetMulti round trip
overall. usageKeysPerScope names the per-scope key stride (was a bare
6, now 8), closing a deferred review follow-up while the count changed
anyway.
Tests: TDD throughout — bucket/window-key math incl. UTC and hour
boundaries, the month step-back day-31 AddDate trap, split accounting,
token budget summing both directions, total-scope counting-without-
evaluating on every metered route, and the full history API (shape,
oldest-first, span bounds, validation table, 404, 503, one-batch
assertion via a counting stub). Integration: TestAdminDashboard now
fetches an hour history series for user alice and for total and
asserts a non-zero current-hour bucket.
Migration: none — old "tok" counter keys simply expire on their
existing TTL; nothing is deployed yet.
Spec §5 (v0.2): Config.ModelAliases map[string]string, alias-first
resolution precedence ahead of provider-prefix splitting, alias-OR-target
authorization, lazy target resolution with a 404 naming both ids,
/v1/models listing, admin overview alias table (JSON + dashboard), and
construction-time validation (shape, prefix-shadow, explicit-model
collision, chain, empty target).
routes_unified.go/routes_media.go needed no resolution-call-site changes
- both already pass the client's raw requested id through to
registry.resolve, so aliases inherit the __alias echo and multipart
rewrite machinery for free. cache.go needed no changes either - cacheKey
already hashes requestedModel, so two aliases resolving to the same
target already produce distinct cache entries.
Adds registry_test.go validation table + precedence/authz/lazy-404/
listing coverage, a dedicated cache-key split test, chat/embeddings/
images/audio-transcriptions end-to-end tests, an extended admin overview
test, and an integration test covering the openai-type vs anthropic-type
echo asymmetry under real Traefik+Yaegi.
Controller-ruled small fixes accumulated from T3/T4 reviews:
1. routes_media.go: multipart Content-Type check is now an exact
mediaType != "multipart/form-data" comparison (HasPrefix admitted
"multipart/form-datax"); a malformed-tail multipart body (fails to
parse at all) now reports "malformed multipart body", distinct from
"model is required" (a body that parses fine but carries no model
part) and "duplicate model field".
2. provider_openai.go: audioTranscription's doc comment no longer claims
the body is always "replayed unchanged"/the boundary always
"verbatim" — it now reflects the rewrite path routes_media.go
already takes for an aliased model id.
3. rewriteMultipartModel: buf.Grow(len(body)+512) up front, avoiding
repeated reallocate-and-copy growth while rebuilding an aliased
multipart body.
4. limits.go currentUsage: guards len(vals) == len(keys) before indexing
storeGetMulti's result, so a non-conforming counterStore's short slice
reports storeDown instead of panicking.
5. admin.go dashboard JS: the 5s poll no longer wipes an existing
auth-error message when no key is stored (only a successful load or a
new submission clears it now); clearStoredKey on a 401/403 now only
fires when the failing request's own key still matches what's
currently stored, guarding against a late response for a key the user
has since replaced.
6. Spec §4's duplicated /admin/api/overview bullet and the
nosniff/no-store sentence's placement were already fixed upstream
(docs/superpowers/specs/2026-08-20-llmgateway-v02-design.md, commit
c02010d) — verified identical, nothing further to commit here.
7. Added TestAdmin_JSONSecurityHeaders (nosniff/no-store/CSP on both
admin JSON APIs) and TestHandleAudioTranscriptions_MalformedTail_400
(covers fix 1's new malformed-body branch).
go test ./... -count=1 -race, gofmt, vet, make yaegi-check, golangci-lint
(incl. gosec) all pass.
Review fixes (2 Important + 4 folded):
1. GET /admin now serves the HTML shell with no authentication -
a browser navigating directly to the URL cannot attach a custom
header. The page reads an admin key from sessionStorage and sends
it as x-api-key on /admin/api/* fetches, falling back to an inline
key-entry form on a missing key or any 401/403. Both JSON routes
keep their original 401/403/200 gate. Spec ss4 amended in place.
2. GET /admin/api/usage no longer serializes 6 GETs per user/group
scope on the shared Redis connection. respClient gains getBatch
(one pipelined round trip); counterStore gains getMulti; limiter
gains storeGetMulti mirroring storeGet's fail-open/fail-closed
policy. currentUsage now issues one getMulti per scope.
3. Provider baseUrl is stripped of userinfo and query before it is
ever echoed in the overview JSON (sanitizeBaseURL), stronger than
net/url's own Redacted (which leaves a bare username in place).
4. limiter.redisStatus now also returns the last failure's timestamp,
so the dashboard can render "last error (Ns ago)" instead of a
bare message that reads as currently-broken indefinitely.
5. Both JSON admin responses now set X-Content-Type-Options: nosniff,
Cache-Control: no-store, and the same CSP the HTML page uses.
6. Added a test proving a file-sourced (replaceFileUsers) admin user
gets the same access an inline admin user does.
go test ./... -count=1 -race, gofmt, vet, make yaegi-check all pass.
Adds GET /admin (single-page dashboard), GET /admin/api/overview, and
GET /admin/api/usage per spec §4: providers/redis/cache/groups summary
and per-user/group current-window usage+limits. Gated by Config.Admin
(default off, not registered when disabled) and UserConfig.Admin.
Gate order: disabled -> 404 fall-through; unauthenticated -> 401;
authenticated non-admin -> 403; admin -> serve, counted against the
admin's own request limits like any other route.
Adds small, read-only, mutex-guarded hooks: modelRegistry.snapshot(),
limiter.currentUsage()/redisStatus(), authStore.snapshot(). No secret
field (API key, digest, redis password) is ever read into any admin
response type; verified by a canary-literal redaction test.
HTML/CSS/JS ship as a single Go raw-string const (Yaegi forbids
go:embed); confirmed interpretable via make yaegi-check.