Commit Graph
285 Commits
Author SHA1 Message Date
lukaszraczylo 92e9ad0c1e fix(webui): selectable chip fallback, precise css fixup docs 2026-08-21 10:52:00 +01:00
lukaszraczylo b39d4e92c9 fix(webui): query-safe expand state, prefixed search, accessible row toggle
Review fix round (5 Important + 3 folded) against the provider-model
accordion (f9b289f):

1. Manual expand state corruption during an active search. Clicking a
row that was only auto-expanded by the search (never manually opened)
read the RAW manual flag as false and added it to manuallyExpanded -
invisible immediately (already showing via auto-expand) but wrongly
left expanded after the search cleared. Fixed with a two-set design in
a new, Vue-free module (webui/src/lib/provider-expand.ts):
manuallyExpanded (the real, cross-search manual state) plus
manuallyCollapsed (an override a click can set to suppress an
auto-expand while a query is active). toggleProviderExpand now flips
against the caller-computed EFFECTIVE current state, not the raw
manual flag. manuallyCollapsed clears whenever the query becomes empty
(watched, not just the clear button, so backspacing to empty resets it
too). Verified two ways: a throwaway Node assertion script (node
--experimental-strip-types, no test-runner dependency added)
exercises the exact review-described sequence against the plain
module; the identical sequence was then reproduced live in a real
browser and screenshotted at each step.

2. Search matched raw model ids while chips display and copy the
prefixed routable id - copying a chip and pasting it into search found
nothing. Fixed: matching is now against routableModelId(p.name, m) for
both the provider-level filter and the per-provider visible-model
filter - strictly more permissive than the bare id. Verified live:
searching the full "openai/gpt-4o-mini" string now matches.

3. role="button" on a <tr> broke table AT semantics - overriding a
<tr>'s row semantics to "button" strips it from the table's row/column
structure for a screen reader. Moved the interactive control to a real
<button type="button"> inside the first cell (the chevron+name span);
the row's own @click stays as a click-anywhere-in-row convenience, and
the button needs no click handler of its own - its native click bubbles
up to the row's listener, so exactly one place ever runs the toggle.
Added a visible focus style matching the existing shadcn-vue
focus-visible convention used elsewhere in this panel - verified live,
keyboard Tab produces a clearly visible ring. aria-controls is set only
while a provider is expanded, never pointing at an id that does not
yet exist (the content row stays v-if, lazy) - verified live via a
direct DOM check. Provider names are interpolated into that id
unescaped, not sanitized in JS - providers.go's configNamePattern
(charset [a-zA-Z0-9._-]) already rejects any character an HTML
id/attribute value would need escaped, before this endpoint can ever
see one.

4. DRY: providerState.knownIDs() and snapshot() independently rebuilt
the identical explicit-union-discovered merge-and-sort. Extracted
knownIDsLocked() (no locking of its own - caller must already hold
st.mu), consumed by both knownIDs() (takes the lock itself) and
snapshot() (already holds the lock, calls the lock-free variant
directly - a call to the locking knownIDs() from inside snapshot would
deadlock the non-reentrant mutex). snapshot()'s own doc comment
rewritten to describe the actual shared-helper shape instead of the
now-removed duplication it used to explain away.

5. (folded) Corrected a wrong claim in OverviewView.vue's own "why not
Accordion/Collapsible" doc comment: a <div> inside a <tbody> does NOT
get reparented by the browser's HTML parser ("foster parenting") in
this app - Vue renders entirely via DOM APIs, never by parsing an HTML
string, so the parser's foster-parenting algorithm (which only runs
building a DOM tree from a token stream) never executes here. What
actually breaks is CSS table layout: CSS2.1 17.2.1's anonymous-table-
object generation misbehaves around a block box sitting directly
inside a table-row-group box. The comment now says this.

6. (folded) Clipboard fallback hardening: ModelChip.vue's doc comment
now states the select-text fallback is the ONLY path a click ever
takes on a plain-HTTP admin deployment (Clipboard API needs a secure
context), not a rare edge case. A genuine copy failure now sets a
distinct "failed" state (destructive-colored icon, "Copy failed"
title, auto-resets) instead of bailing silently.

Regenerated via make admin-ui; admin_assets_gen.go = 850,968 bytes.
Gate: go build/gofmt/vet/fieldalignment clean, go test -count=1 ok
6.4s, go test -race ok 7.6s, golangci-lint clean (1 pre-existing
unrelated resp.go finding), make yaegi-check OK, npm run build clean,
make integration ok 45.0s (needed 3 attempts - TestUsersFileHotReload
is a pre-existing flake, now observed at least 3 times across this
body of work, unrelated to this diff; flagged for follow-up, not
fixed here).
2026-08-21 10:42:48 +01:00
lukaszraczylo f9b289f1ea feat(webui): provider model accordion
Clicking a provider row in the dashboard expands to show its model
list, with a search filter and copy-to-clipboard on each model.

Backend (registry.go/admin.go): providerSnapshot gains models []string
- the sorted explicit union discovered id set, computed in the SAME
providerState.mu critical section as lastRefresh/lastErr (not via a
separate knownIDs() call, which takes the same non-reentrant lock and
would deadlock) so the three values are never assembled from two
separate locked passes a concurrent finishRefresh could interleave
between. adminProviderView exposes it as models (never nil - a real,
if empty, JSON array). New tests: overview shape asserts sorted
models per provider; a new TestModelRegistry_Snapshot_ModelsStaleWhileError
proves a failed refresh after an earlier success keeps the previous
discovered set (existing stale-while-error semantics, no special
handling added); TestAdmin_SecretRedaction unaffected (model ids are
not secrets, still passes as-is).

Frontend: the accordion is NOT shadcn-vue's Accordion or Collapsible -
both wrap trigger+content in one div, which cannot legally sit
between two tr elements inside a table/tbody. Installed
Collapsible via the CLI to check (verified components.json's now-
defused font/iconLibrary omission holds under a real install: main.css
stayed byte-for-byte unchanged) and inspected its source: reka-ui's
CollapsibleContent does support an "as" prop that could force it onto
a tr, but its animation-measurement code (getBoundingClientRect +
inline transition styles) is untested in that shape and not worth
taking on for an instant show/hide with no transition - removed the
installed files, kept a plain reactive Set<string> + v-if second
TableRow, which reuses Accordion's own visual language (rotating
chevron, keyboard-accessible trigger row: role=button, tabindex,
Enter/Space handlers) without misusing a component built for block
content.

Model chips (new ModelChip.vue) show the full ROUTABLE id -
"{providerName}/{modelId}" unconditionally, so a discovered id that
already contains a slash (uni/deepseek-v4-flash-0731 on provider
"real") renders as real/uni/deepseek-v4-flash-0731 and stays
copy-paste-valid - and copy that id to the clipboard on click
(lib/clipboard.ts: navigator.clipboard.writeText with a
select-the-text fallback when the Clipboard API is unavailable, never
the deprecated execCommand('copy')), with a brief checkmark
copied-state affordance. No CSP change: Clipboard access is a browser
permission, not a CSP directive.

Search input (OverviewView.vue) filters the providers table to
providers with a matching model (case-insensitive substring) and the
aliases table to aliases whose own name or target matches, auto-
expanding matching providers while a query is active and filtering
each expanded provider's own chip list down to just its matches (so a
72-model provider does not stay a 72-chip wall for a 1-model search).
Manual expand/collapse state is tracked separately from the search's
own auto-expand set, so clearing the query restores exactly what the
user had open by hand - nothing to explicitly "restore", since the
auto-expand set was never written into the manual one.

Integration: TestAdminDashboard's overview assertion extended
(adminProviderModels helper) to check the openai provider's models
array contains dynamic.yml.tmpl's explicit "gpt-mock" model - stays
API-level (asserts the JSON payload), not the rendered HTML, so the
prefixed-display-form request is left at the frontend only per scope.

Regenerated via make admin-ui; admin_assets_gen.go included.
2026-08-21 10:18:57 +01:00
lukaszraczylo 1aa2b43932 fix(webui): defuse scaffold config traps, tighten inline guards
Micro-fix from re-review:

1. webui/components.json: deleted the font/iconLibrary keys instead of
   picking a value. Re-review checked shadcn-vue's actual parse schema
   (node_modules/shadcn-vue/dist/schema/index.js's rawConfigSchema):
   both fields are z.string().optional(), not an enum - the five-choice
   enum lives only on the init CLI's --font/--icon-library flags, not on
   the components.json schema. The previous commit's "schema forbids a
   fix" rationale was wrong; corrected here. Verified empirically, not
   just by reading the schema: ran a real `npx shadcn-vue add badge -o
   -y` against components.json with both keys removed - src/assets/
   main.css came back byte-for-byte unchanged (previously this exact
   class of call re-added a Google Fonts import and the .dark-class
   toggle). That same run did still re-add @lucide/vue to package.json's
   dependencies (some components' registry entries list an icon package
   regardless of iconLibrary) - reverted before committing, and
   webui/README.md's warning section rewritten to explain the omission
   (so nobody "helpfully" restores the keys), fix the schema claim, and
   note the narrower remaining package.json trap.

2. admin.go:27-28: adminCSP's doc comment now names all three set-sites
   (GET /admin, every GET /admin/assets/*, the three JSON routes) -
   previously only mentioned the page and JSON routes, dropped after the
   asset route gained the same header in the prior fix round.

3. Both inline-script guards (webui/generate.mjs's assertNoInlineAssets,
   admin_test.go's hasInlineScript) tightened from a bare \bsrc\s*=
   pattern to (^|\s)src\s*= - \b alone matches inside "data-src=" too
   (the "-" before "src" is a non-word character, so \b sits right
   before "src"), which would wrongly treat a data-src/nosrc attribute
   as the real src= that makes a <script> external. New Go test
   TestHasInlineScript_DataSrcIsNotExternal: <script data-src="x">
   is still flagged as inline.

No webui/src/ change this round - components.json isn't bundled, and
generate.mjs's edit only touches its own build-time guard, not the
Go-source-generation path. admin_assets_gen.go NOT regenerated
(confirmed via a throwaway `node generate.mjs` + gofmt -w run: output
is byte-identical to what's already committed).
2026-08-21 09:54:48 +01:00
lukaszraczylo b98416d1e9 fix(webui): version display, CSP guards, config traps, bucket-aligned stacking
Review fix round (4 Important + 2 folded):

1. Version display: OverviewView.vue was missing the muted version line
   the old vanilla-JS page rendered (types/api.ts already declared
   overview.version) - added.

2. CSP invariant now guarded twice: webui/generate.mjs's new
   assertNoInlineAssets fails the build if dist/index.html contains an
   inline <script> (no src= attribute) or any <style> tag - the exact
   condition the tightened admin CSP (script-src/style-src 'self', no
   'unsafe-inline') depends on. admin_test.go's new
   TestAdminIndexHTML_NoInlineAssets re-asserts the identical invariant
   server-side, against the decoded adminIndexHTML admin.go actually
   serves, so a regression is caught even if admin_assets_gen.go were
   regenerated with a modified copy of generate.mjs.

3. webui/components.json's iconLibrary:"lucide"/font:"inter" fields
   cannot be fixed in place - checked shadcn-vue's own schema
   (PRESET_ICON_LIBRARIES/PRESET_FONTS in the installed package): no
   "none"/fontawesome/system choice exists for either, and JSON (unlike
   the tsconfig.*.json files touched in the previous commit) has no
   comment syntax to warn inline. Documented instead: webui/README.md
   gained a loud warning section naming the exact regression (already
   hit twice this branch - a re-added Google Fonts import, a re-added
   lucide icon import) and the two-step fix to apply after any future
   shadcn-vue add/init call.

4. Real-CSP verification: the browser pass earlier in this branch ran
   under vite preview, which sends no CSP header at all. New
   integration/integration_test.go: TestAdminAssetCSP fetches GET
   /admin against the real Traefik+Yaegi pipeline, extracts the asset
   URL from its <script src="..."> tag, fetches that asset, and asserts
   both responses carry the exact adminCSP string byte-for-byte.
   admin.go's serveAdminAsset now also sets Content-Security-Policy
   (previously JSON/page routes only) so this holds for a direct
   navigation to an asset URL, not just as a same-origin sub-resource of
   GET /admin; admin_test.go's TestAdminAsset_Serving updated to match.

5. (folded) UsageChart.vue stacked the "tokens" tab's two series
   (tokin/tokout - two separate HTTP requests, not one atomic call) by
   positional index. A window rollover landing between the two requests
   could leave one series one bucket ahead, mis-stacking that refresh.
   Now aligns both series by their bucket key (a sorted union of every
   active series' buckets - the fixed-width, zero-padded bucket strings
   sort chronologically by construction), with a missing bucket in one
   series reading as 0 rather than shifting the whole series.

6. (folded) AuthGate.vue now triggers an immediate dashboard.refresh()
   on successful key submit, instead of waiting up to 5s for the next
   poll tick - the dashboard no longer sits blank right after auth.

Rebuilt via make admin-ui; admin_assets_gen.go regenerated.
2026-08-21 09:43:24 +01:00
lukaszraczylo afdb7415db docs: vue admin panel, usage charts, stats exclusion
README.md: ## Admin rewritten for the Vue rebuild (webui/, new
GET /admin/assets/{hashedname} route, tightened CSP with no more
'unsafe-inline', usage-charts description) and the stats-exclusion
directive ("Admin traffic is never counted" replaces "Poll counts
against quota"). ## Development gains `make admin-ui` plus a note
that Node is needed only for that one target.

docs/superpowers/specs/2026-08-20-llmgateway-v02-design.md §4: an
amendment block documents the Vue rewrite (new route, tightened CSP,
Charts view) following this doc's established
controller-approved-amendment convention; the Accounting bullet is
amended for the stats-exclusion operator directive, with the original
wording struck through rather than deleted.
2026-08-21 09:25:02 +01:00
lukaszraczylo 9fced97050 feat(admin): exclude admin traffic from usage statistics
Operator directive (progress ledger, 2026-08-21): admin requests must
not touch req/min-req/day statistics. handleAdminAPI no longer calls
checkAndCount for GET /admin/api/overview or GET /admin/api/usage (it
already skipped it for GET /admin/api/usage/history) - admin traffic
must never appear in usage statistics, which measure real LLM traffic
only. identify()'s group return is now discarded (u, _, ok :=), since
buildLimitScopes is no longer built here.

Direct consequence, documented in the new doc comment: an admin's own
requestsPerMinute/requestsPerDay limit, if configured, is no longer
enforced against admin-route traffic either - accepted trade-off
(admin-gated, cheap reads; a self-inflicted, not shared, resource
cost).

admin_test.go: TestAdmin_RateLimitApplies (asserted a 2nd request
429s) inverted to TestAdmin_NeverRateLimitedAndCountersUntouched - 5x
requests to both overview and usage against an admin key pinned at
RequestsPerMinute:1, asserting every one is 200 and the admin's own
req/min counter stays exactly 0.

integration/integration_test.go: TestAdmin_Dashboard's admin-user
assertion inverted from requestsPerDay > 0 ("admin API calls count
too") to requestsPerDay == 0 ("admin routes never call
checkAndCount") - the admin key is used throughout the whole suite
exclusively to hit /admin/api/*, so it must show exactly 0 despite
issuing many requests by the time this test runs.
2026-08-21 09:24:35 +01:00
lukaszraczylo 7b28f34bb2 feat(admin): serve baked vue panel with usage charts
admin.go: GET /admin now serves the generated adminIndexHTML
(admin_assets_gen.go, webui/generate.mjs's output) instead of the old
adminPageHTML Go-string const (removed, ~340 lines, plus its now-dead
io import). New route GET /admin/assets/{hashedname}
(adminAssetsPathPrefix) serves the built app's content-hashed JS/CSS
from the generated adminAssets map - unauthenticated (same rationale as
GET /admin itself), Content-Type per asset, Cache-Control: public,
max-age=31536000, immutable, X-Content-Type-Options: nosniff; an
unknown name is a 404 in the standard OAI error envelope.

CSP tightens: script-src/style-src drop 'unsafe-inline' for 'self' (the
built app has no inline script or style of any kind - vite.config.ts's
build.modulePreload.polyfill:false sees to that), img-src gains 'self'
data:' for the shell's inlined-SVG favicon.

Makefile: new admin-ui target (npm ci && vite build, then
generate.mjs, then gofmt -w) - the only place Node is invoked; CI and
every other make target still need nothing beyond Go.

admin_test.go: TestAdminPage_CSPHeaderAndFetchURLs rewritten as a shell
test against the generated file (new CSP string, body references
/admin/assets/ and the <title>'s "LLM Gateway", no external
src=/href= reference - the favicon's xmlns attribute is excluded from
that check, it names an XML namespace URI, not a fetched resource);
TestAdmin_JSONSecurityHeaders' expected CSP updated; new
TestAdminAsset_Serving (Content-Type/nosniff/immutable-cache/byte-exact
body), TestAdminAsset_UnknownReturns404, and
TestAdminAssets_GeneratedFilePresent (non-empty index.html/assets, no
leftover {{/}}/generator-marker text leaked into the decoded HTML -
proves the base64 round-trip actually worked).
2026-08-21 09:23:37 +01:00
lukaszraczylo 655bbcbf2c feat(webui): vue admin panel source
Vue 3 + TypeScript + Pinia + Tailwind v4 + shadcn-vue + Chart.js admin
panel source under webui/, built with Vite. Views: Overview (providers,
redis/cache/retry status, model aliases), Usage (per-user/per-group/total
tables with tokensIn/tokensOut/cost + limits), Charts (scope selector,
req/tokens(stacked in-out)/cost metric tabs, 24h/30d/12mo window
switcher, fed by GET /admin/api/usage/history). Pinia stores: auth
(sessionStorage key gate, x-api-key header, 401/403 re-prompt),
dashboard (5s poll), history (fetch-on-selection + 30s refresh).

Design: deliberate neutral warm-neutral palette with a signature teal
accent (no default Inter/purple-gradient/glassmorphism), real
typographic hierarchy via the platform UI/mono font stacks (zero
external assets - no Google Fonts), dark+light via prefers-color-scheme
(no class toggle, no theme switcher). Charts: zero-based bars, honest
axes, Okabe-Ito colorblind-safe series, tokens-in/out as a
distinguishable stacked pair.

vite.config.ts: base '/admin/' (matches admin.go's route layout) and
build.modulePreload.polyfill:false (no inline bootstrap script, so the
tightened admin CSP needs no 'unsafe-inline'). Code-split: Chart.js
(ChartsView) loads lazily, not in the initial bundle.

generate.mjs reads dist/ after `vite build` and writes
../admin_assets_gen.go (not committed by this change) - base64-encodes
every asset, decoded once at package-var init; documented rationale in
the script's own header comment.

Hygiene: webui/go.mod walls webui/ off from the repo root's `go build`/
`vet`/`test ./...` (npm's node_modules bundles a stray .go file that
would otherwise be swept in); tools/yaegi-check/main.go excludes webui/
from its GOPATH copy for the same reason. webui/ export-ignored
(.gitattributes) from release source tarballs; webui/node_modules/
gitignored. tsconfig.app.json/tsconfig.node.json's JSONC comments
dropped (TS itself is fine with either; the global pre-commit JSON
validator is not) - no config values changed.
2026-08-21 09:21:41 +01:00
lukaszraczylo 47b22d2d7f docs(limits): accurate fallback charting horizon and call-site attribution
1. README's fallback-charting story corrected: the 48h ceiling
   (memoryStoreMaxTTL) only binds hour and day, not month. hour charts
   its full default 48-bucket span; day shows only its most recent
   ~2-3 of the 35 possible buckets (its key still sits under the 48h
   ceiling). month is different: enforceTTLFor's ~32-day floor wins
   over the 48h ceiling there, so the CURRENT (in-progress) month's
   bucket charts in full on the fallback — only past months are
   unavailable, once their own key expired ~32 days after its own
   creation. Aligns with limits.go's clampTTL/enforceTTLFor and the
   +5d TestMemoryStore_IncrMulti_MonthCounterSurvivesPast48h... test
   from the prior round.

2. limits.go's "5 call sites, all in admin_test.go" attribution
   belonged to incrCounter, not incrBy — incrBy itself (memoryStore.
   incrBy and redisStore.incrBy together) has 11 direct test call
   sites. Fixed at both doc-comment sites (the counterStore interface
   doc and memoryStore.incrBy's own doc); the no-production-caller
   claim is unchanged and correct for both.

3. Appended the missing "Fix round 2" section to
   .superpowers/sdd/usage-history-report.md, documenting the
   enforcement-floor design (counterIncr.enforceTTL, enforceTTLFor,
   newCounterIncr, clampTTL's ceiling-vs-floor precedence) that this
   report's round-1 section never described. Corrected that same
   section's stale "incrMulti's loop inherits the clamp for free,
   since it calls incrBy" sentence — round 2 replaced that shape:
   incrMulti now clamps its own entries via the shared applyIncr
   primitive and no longer calls incrBy at all.

Gate: go test ./... -count=1, gofmt, vet — all green. Docs and
comments only, no behavior change.
2026-08-21 01:25:27 +01:00
lukaszraczylo f9fa14ef3f fix(limits): enforcement-aware fallback TTL clamp
Re-review round on 5cc6814 found round 1's 48h fallback clamp
defective: it broke TTL > window-length for the MONTH window on
memoryStore, turning tokensPerMonth/costPerMonthUSD into a rolling
~48h budget (fail-open) whenever Redis is absent or down.

1. counterIncr gains enforceTTL time.Duration — the minimum retention
   window's own enforcement correctness requires (enforceTTLFor:
   min->2m, hour->2h, day->25h, month->32*24h, matching each window's
   pre-history-bump TTL). newCounterIncr builds every checkAndCount/
   account entry through one helper so a (window, ttl) pair can never
   reach incrMulti without its matching floor. memoryStore's clampTTL
   now takes (ttl, enforceTTL) and applies max(min(ttl,
   memoryStoreMaxTTL), enforceTTL): the 48h ceiling still bounds
   min/hour/day (unaffected/still-capped as before), but the floor
   wins for month, landing it back at ~32 days (1-2 live keys per
   scope/metric — negligible against the day-key savings that
   motivated the ceiling). redisStore ignores enforceTTL; incrBy's own
   single-key path passes enforceTTL=0 (ceiling only, unchanged
   behavior — it has no window to derive a floor from and has no
   production caller since the batching, see (3) below).

2. limits.go's TTL-invariant comment restated precisely: the consts
   still satisfy TTL > window-length exactly as applied to Redis; that
   did not automatically extend to memoryStore's own effective TTL
   after its ceiling, which is what round 1 broke and round 2 fixes.
   README: the Limits and accounting "Fallback" bullet now says
   enforcement stays correct on the fallback at every window
   (including tokensPerMonth/costPerMonthUSD), and that the 48h
   ceiling applies to usage-history CHARTING retention only.
   LimitsConfig's window table (tokensPerDay/tokensPerMonth rows)
   cross-links it. The "resets on a rebuild" bullet's stale
   limiter.storeIncrBy reference is corrected to storeIncrMulti and
   now distinguishes rebuild-loss from the fallback's own per-window
   TTL expiry.

3. counterStore.incrBy, limiter.storeIncrBy, and limiter.incrCounter's
   doc comments now state plainly they have no production caller since
   the batching (perf review, 2026-08-21) and are kept for direct
   counter seeding in tests — 5 call sites, all in admin_test.go's
   TestAdminUsage_MathAgainstSeededCounters, correcting the prior fix
   report's "dozens of tests" overclaim to the actual count.

Tests: TestClampTTL_ExactMathTable pins the ceiling-vs-floor
precedence for every window's real (ttl, enforceTTL) pair.
TestEnforceTTLFor_MatchesTheOriginalPreHistoryBumpValues pins the four
floor values directly. TestMemoryStore_IncrMulti_
MonthCounterSurvivesPast48hAndStillEnforces drives a real
TokensPerMonth budget through account/checkAndCount on the fallback
5 days in and confirms both survival and enforcement.
TestMemoryStore_IncrMulti_DayCounterStillClampsAt48h confirms day
still resets at the 48h ceiling (its own floor sits below it).

Gate: go test ./... -count=1, -race, gofmt, vet, make yaegi-check —
all green.
2026-08-21 01:17:15 +01:00
lukaszraczylo 5cc681496c perf(limits): single-batch counter writes, bounded fallback retention
Review fix round (2 Important + 5 folded) on 08cfdb4/b3fb705.

1. counterStore gains incrMulti(entries []counterIncr) ([]int64, error).
   redisStore.incrMulti sends every entry's INCRBY+EXPIRE pair as ONE
   pipeline (ttlToSeconds extracted from incrBy, now shared);
   memoryStore.incrMulti loops incrBy for interface conformance.
   checkAndCount batches every scope's req:min/req:day/req:hour into one
   storeIncrMulti call (down from up to 9 round trips for a 3-scope
   request) and reads req:min/req:day back from the reply, no extra
   GETs. account batches every scope's nonzero tok-in/tok-out/cost
   entries into one call (down from up to 27). Violation math and
   fail-open/fail-closed/latched semantics are unchanged — only the
   round-trip count moves.

2. memoryStoreMaxTTL (48h) caps every TTL memoryStore actually applies,
   clamped inside incrBy so incrMulti's loop inherits it for free. The
   in-process fallback is continuity, not history: applying the real
   hour/day/month TTLs there was growing live-key count ~30x, multiplying
   sweepEvery's own documented "167x incrBy cliff" by the same factor.
   Sweep cadence is unchanged (still 30s-gated); the clamp shrinks what
   there is to sweep instead.

Folded: historyMaxSpan's margin comment corrected (~4 days, not a
month); checkAndCount's godoc names req:hour; the integration
current-hour assertion sums every point instead of reading only the
last one (an hour-rollover race the suite cannot control); a new
exact-boundary test proves tokin+tokout landing exactly on a
TokensPerDay limit still violates; README documents the history route
as unthrottled (admin-gate is its only trust boundary) and the
fallback's 48h retention ceiling.

Tests: countingIncrStore proves exactly one incrMulti call per
checkAndCount and per account, with correct per-scope/metric/window
values and unchanged violation math; a fake-RESP-server test pins
incrMulti's wire format (one pipeline, entry-ordered replies); TTL
clamp tests cover both the ceiling and the pass-through case at the
boundary.

Gates: go test ./... -count=1, -race, gofmt, vet, golangci-lint,
make yaegi-check all green. make integration: every test passes
except the pre-existing TestUsersFileHotReload flake, which is NOT
caused by this change — verified via an isolated worktree at the
prior commit (b3fb705) run under identical conditions (full suite,
same host), which fails identically. This host's Docker Desktop is
currently slow to propagate the bind-mounted users.json change into
the containers; auth.go/users_file.go are untouched by this diff.
2026-08-21 01:02:28 +01:00
lukaszraczylo b3fb705b36 docs: document the tokens-in/out split, total scope, and history API
Extends Limits and accounting and Admin for the v0.2 data-layer
change (08cfdb4): the tokin/tokout counter split and how a
TokensPerDay/TokensPerMonth limit still reads their sum, the
synthetic total scope, the stats-only hour window, the retention TTL
bump, GET /admin/api/usage/history's query parameters and error
shapes, and the admin usage API's new tokensInPerDay/tokensOutPerDay
fields and total row. One-sentence migration note: old "tok" keys
expire on their existing TTL, nothing deployed.
2026-08-21 00:34:23 +01:00
lukaszraczylo 08cfdb4d99 feat: split token counters and hour-resolution usage history
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.
2026-08-21 00:33:41 +01:00
lukaszraczylo 611e9c04bc docs(hot-reload): honest settle semantics and TTL comment
Micro-fixes from re-review, all text/bound-level, no behavior change to
production logic:

1. settleToBaseline's doc comment (config_hot_reload_test.go) claimed to
   guard the propagation window unconditionally. Reworded: it is a real
   guard on the fired path (two known rebuilds to wait out), best effort
   only on the skip path, where no amount of polling can prove a rebuild
   will never eventually fire — waiting is still required there because
   the earlier disappear-only check was a guaranteed-zero-wait bug, not
   because settling can prove safety on that path.
2. Restored the cleanup settle bound from 20s to 30s, matching the
   longest propagation delay actually observed during verification.
3. README's Config hot-reload intro dropped "on every run" and now
   states the same fired-vs-skip distinction the test's own Skip message
   makes: zero non-200s is blip-free-reload evidence only when the watch
   fires within the test's bound; otherwise it shows just that the stack
   survived an unreloaded rewrite attempt.
4. auth.go's group.cacheTTL field comment still said a positive value
   "overrides it for this group's cache entries only" — reworded to the
   corrected write-time/shared semantics: it sets the TTL written when
   that group's own request populates an entry, entries are shared
   across groups, not scoped by group.

Gate: go build/vet/gofmt clean on both modules; go test ./... -count=1
green.
2026-08-21 00:04:27 +01:00
lukaszraczylo c1bb9fe87a fix(hot-reload): honest reload docs, unconditional settle, isolated probe budget
Review response (5 Important + 3 folded):

1. README's cacheTTL row/bullet implied per-group scoping; cacheTTL sets
   the TTL written when a group's own request populates a shared cache
   entry, it never scopes the entry itself. Reworded only, cacheKey
   unchanged.
2. README's fallback-counter claim ("used only when redis is not
   configured") was wrong: limits.go also falls back during a Redis
   outage whenever failOpen is true (the default). Reworded against
   limiter.storeIncrBy/storeGet.
3. Dropped the unevidenced "in-flight requests finish on the old
   instance" claim; replaced with what TestConfigHotReload actually
   observes (zero non-200s across the rewrite window, every run).
4. Real bug: cleanup's settle poll checked for the injected alias to
   disappear, which trivially no-ops on the skip path (the alias was
   never live, so "absent" was already true on the first check) —
   exactly the unguarded window that caused the earlier 404 cascade.
   Replaced with settleToBaseline: captures the baseline /v1/models id
   set up front, then polls unconditionally (both paths) for 3
   consecutive matching polls, bounded 20s. Verified against a live run
   that actually hit the skip path.
5. The ~20rps probe loop shared aliceKey/"eng" (1000/min) with most of
   this suite's other tests; a budget-exhaustion 429 would look identical
   to a reload blip. Added a dedicated hotreload group
   (requestsPerMinute: 100000) and hotreload-probe user to
   dynamic.yml.tmpl; every request TestConfigHotReload issues now uses
   that key.
6. examples/docker-compose.yml still shipped providers.file.filename,
   contradicting the new guidance; switched to providers.file.directory.
   Verified by actually running the demo stack.
7. waitForModelAliasBounded and the new settle poll now use
   doJSONNonFatal (returns an error instead of calling t.Fatalf), so a
   transient transport error resets that poll's retry state instead of
   aborting the whole test.
8. Reworded the skip-path Skip message: zero failures there proves only
   that the stack survived an unreloaded rewrite attempt, not that a
   successful reload is blip-free. Re-added the old single-file path to
   .gitignore alongside the new directory entry.

Gates: go build/vet/gofmt clean on integration/; root go test -race,
gofmt, vet, yaegi-check all clean; make integration run cold twice, both
exit 0, plus a verbose run that landed on the skip path (the exact
scenario item 4 fixes) with the full suite, including
TestUsersFileHotReload, passing immediately after.
2026-08-20 23:57:05 +01:00
lukaszraczylo 945f41b9b0 docs: kubernetes hot-reload guidance
README Kubernetes section: what a Middleware CR edit actually does
(Traefik builds a new instance, warm-fills discovery, swaps it in; requests
in flight finish on the old one), what survives a rebuild (Redis-backed
counters and cache — windowKey's key never carries instance identity) vs
what resets (in-memory fallback counters, down-latches, the discovery
cache), why new models via discovery need no config edit, why the users
Secret must be a whole-volume mount (subPath never receives a kubelet
update) and how the plugin's own 5s mtime poll picks up the resulting
symlink swap, and why a file-provider dynamic config needs directory:
rather than filename: in Kubernetes (ConfigMap symlink swap) and under
Docker Desktop (VirtioFS fsnotify) alike.

examples/kubernetes.yaml: annotated the existing (already-correct,
no-subPath) users Secret volume mount explaining why subPath would
silently break Secret updates, so a future edit doesn't 'simplify' it
into one.
2026-08-20 23:38:17 +01:00
lukaszraczylo 24f4a2d8c5 test(integration): config hot-reload proof via directory provider
Single-file file-provider watch (providers.file.filename) never fires
under Docker Desktop's VirtioFS bind mount — empirically confirmed
against this stack. Traefik's directory-based file provider
(providers.file.directory) is the fix, and is also the correct pattern
for a Kubernetes ConfigMap mount (a symlink swap, which a single-file
watch never observes either).

integration/traefik/traefik.yml: providers.file.filename ->
providers.file.directory (watch: true unchanged). docker-compose.yml:
both traefik1 and traefik2 now bind-mount integration/traefik/dynamic/
(a directory) instead of the single rendered dynamic.yml. Makefile's
integration-up now mkdir -p's and renders into
integration/traefik/dynamic/dynamic.yml; .gitignore updated to match.

New TestConfigHotReload (build tag integration): baseline chat succeeds,
then a sustained ~20rps request loop runs against an existing model
while the rendered config is rewritten (temp file + os.Rename, atomic,
mirroring a k8s symlink swap) to add a new model alias. /v1/models is
polled up to 30s for the alias to appear. The request loop's zero-
failures property is asserted unconditionally, whether or not the watch
fired within the bound; only when it fired does the test also chat
through the new alias and expect 200. Empirically, on this host the
directory watch fires reliably but with variable latency (observed
under 1s up to several seconds; one run exceeded the 30s bound and hit
the Skip path with zero request failures throughout).

Found and fixed during verification: the test's cleanup step now writes
the restore atomically and WAITS for it to propagate before returning.
Without that wait, a delayed rebuild triggered by the restore could land
during a later test's own non-atomic config write (TestUsersFileHotReload's
users.json), reading a torn file mid-write and failing plugin
construction outright — a global 404 outage until the next unrelated
config change happened to trigger a successful rebuild. Reproduced and
confirmed fixed: two full `make integration` runs (cold teardown-to-up,
then immediately again) both pass end to end.
2026-08-20 23:37:53 +01:00
lukaszraczylo 1ae8555255 feat: per-group cache TTL
GroupConfig gains CacheTTL (json cacheTTL, empty inherits the global
cache.ttl). Parsed and validated at construction (newAuthStore,
groupCacheTTL in cache.go): a malformed duration, a duration <= 0, or
CacheTTL set while the global cache block is not configured is a
constructor error naming the group, mirroring Cache:true's existing
"nothing to inherit from" check.

group carries the resolved time.Duration (0 means inherit).
responseCache.store gains an explicit ttl parameter; routes_unified.go
computes effectiveTTL(g.cache, grp) immediately before each store call,
so setEx's Redis-native EX seconds reflect the group's own override.

Tests: constructor-error table, empty-inherits and valid-override
resolution, effectiveTTL inheritance/override, a scripted-RESP-server
proof that store's SET...EX carries a different value per group, and an
end-to-end Gateway.ServeHTTP test with two groups asserting the actual
wire EX value (30 vs 60) differs per group.

README: GroupConfig.cacheTTL config row and a Caching-section paragraph.
2026-08-20 23:25:06 +01:00
lukaszraczylo 8eb06a4b8b test(integration): enable discovery on the real-upstream provider
Discovery failures are non-fatal (explicit model keeps resolving
offline), and with discovery on, INTEGRATION_REAL=1 runs serve the
gateway's full live catalog from /v1/models instead of one pinned id.
2026-08-20 23:15:13 +01:00
lukaszraczylo 657480348b fix: v0.2 final review wave
Transcriptions Yaegi coverage via integration mock, flattened admin
usage batch, hardened CSP, sanitized provider lastErr, source-archive
prefix template, vendor export-ignore warning, release paths.
2026-08-20 22:46:06 +01:00
lukaszraczylo fcad24c2f0 fix(ci): real source archive via git export-ignore, current stamping docs 2026-08-20 22:06:15 +01:00
lukaszraczylo 64eef947a7 ci: release chain — shared workflows, semver wording, source-only goreleaser, version stamping 2026-08-20 21:48:11 +01:00
lukaszraczylo 7621733c88 test: consolidate resp write-error cases, honor lock preconditions
Review fixes (Task 8, second pass):

- Fold TestRESPClient_AttemptPipelineLocked_WriteError and
  TestRESPClient_HandshakeLocked_WriteError into one table,
  TestRESPClient_WriteError_BrokenPipe — both exercised the identical
  broken-pipe shape on a pre-set fakeConn.
- Take c.mu.Lock()/Unlock() around every direct call to an internal
  "*Locked" method (ensureConnLocked, attemptPipelineLocked x2,
  handshakeLocked) per each method's own "callers must hold c.mu"
  precondition, even though every such test is single-goroutine.
- TestUnifiedCostMicros case 2: replace a want that called costMicros
  (the function under indirect test) with the literal 11_250_000, hand-
  derived from builtinPricing["gpt-5"] (InputPerM 1.25, OutputPerM 10)
  at 1M prompt + 1M completion tokens.
- TestRedisStore_Get_DownServer_ReturnsError: assert err.Error() contains
  "redisStore: get", not just that an error occurred.

Report corrected: the earlier claim that all new tests were table-driven
was inaccurate (actual split 8/16 at review time); documented as a
deliberate choice, not an oversight — six single-scenario tests stay flat
since a one-row table adds structure without adding coverage.

Gate: go test ./... -count=1 (pass), -race (pass), gofmt (clean), vet
(clean).
2026-08-20 21:42:40 +01:00
lukaszraczylo aee3908bc4 test: testify table-driven coverage for low-coverage branches
Lifts all 14 functions the operator flagged as <75% (measured at ed2119c)
to >=75%, using table-driven tests (require for fatals, assert for checks):

- limits.go: failPolicyGet (50.0% -> 100.0%)
- provider_gemini.go: httpClient (0.0% -> 100.0%)
- redis_store.go: get (71.4% -> 78.6%)
- resp.go: dialTimeoutFor, respDeadlineExceededErr.{Error,Timeout,Temporary},
  ensureConnLocked, getBatch (66.7-30.0% -> 91.3-100.0%)
- routes_media.go: resolveMediaRequest (50.0% -> 100.0%)
- routes_unified.go: unifiedCostMicros (66.7% -> 100.0%)
- translate_anthropic.go: isTruthy, toFloat64 (50.0/33.3% -> 100.0%)
- translate_gemini.go: geminiToolConfigFromOpenAI (66.7% -> 100.0%)

resp_test.go adds a hand-rolled fakeConn (net.Conn with injectable
Write/SetDeadline errors) to reach two respClient error branches a real
TCP connection cannot be coaxed into deterministically.

Total coverage: 90.7% -> 92.4% (no regression; report has the full
per-file table). Zero production-code changes. Existing tests untouched:
every diff is a pure addition of new test functions and their imports.

Gates: go test -count=1 (pass), -race (pass, 5x incl. an isolated
baseline-vs-branch comparison after one flaky run implicated pre-existing
registry_test.go timing tests this task never touched), gofmt/vet (clean),
golangci-lint incl. gosec and fieldalignment (0 issues), make yaegi-check
(OK), make integration (pass, untouched-green).
2026-08-20 21:33:08 +01:00
lukaszraczylo f7d9208bde chore(deps): vendor testify
Add github.com/stretchr/testify (test-only) as a direct dependency and
vendor it, matching the traefikoidc precedent: _test.go files are never
Yaegi-interpreted, so vendoring a test-only dependency does not affect the
plugin's runtime under Traefik or the catalog analyzer.

tools/yaegi-check/main.go: add vendor to the harness's repo-copy exclude
list, since vendor/'s nested module layout (go.yaml.in/yaml/v3 alongside
testify) has nothing to do with the plugin package Yaegi imports.

Verified: make yaegi-check stays green; go build/vet/test unaffected.
2026-08-20 21:31:42 +01:00
lukaszraczylo ed2119cc99 fix(aliases): dedupe listing against emitted entries, not raw owners
listFor's alias dedupe keyed on the raw owners map, so a colliding
discovered model whose provider is group-denied caused BOTH the real
entry and the alias to be skipped - the id vanished from the listing
entirely even though resolve still serves it via the alias.

Track ids actually appended to out in a listed map instead, and skip
the alias only when listed[alias] is true. Extends
TestModelRegistry_ListFor_Alias_DedupedAgainstDiscoveredCollision with
a provider-denied variant: group providers:["openai"], collision on
the denied "acme" provider -> alias listed exactly once, owned_by
openai.
2026-08-20 21:11:34 +01:00
lukaszraczylo f0730a70d7 fix(aliases): deterministic integration gate, warn-first fast path, deduped listing
Review fix, task 6 (1 Important + 3 folded):

1. TestModelAliases now polls GET /v1/models (waitForModelAlias) until
   "aliased/mock" appears, bounded 30s, before asserting - removes the
   stale-dynamic-config propagation window a plain healthy-200 check
   cannot rule out. Confirmed config-propagation, not alias-logic, per
   reviewer's config+error-text analysis.
2. registry.go resolve(): check the warned-set (aliasShadowWarned) before
   paying bareWinner's O(providerNames) scan on every aliased request -
   only the first request for a colliding alias pays that cost now.
3. listFor: skip an alias entry whose id already exists in owners (a real
   model already owns it) - previously produced duplicate ids when a
   discovered model collided with a configured alias. resolve's own
   alias-wins precedence is unchanged; only the listing is deduped. New
   test: TestModelRegistry_ListFor_Alias_DedupedAgainstDiscoveredCollision.
4. newAliasTestRegistry doc comment: "grp's given aliases" -> "the given
   aliases" (no grp parameter exists).
2026-08-20 21:06:00 +01:00
lukaszraczylo 2090b31ae2 feat: operator-defined model aliases
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.
2026-08-20 20:56:01 +01:00
lukaszraczylo f49c51b341 fix(docs,integration): example env completeness, flaky reset, cache key assertion
Task 5 re-review fixes: 1 Important + 3 folded.

1. (Important) examples/kubernetes.yaml was construction-broken on
   copy-paste: the inline dashboard-admin user resolves env:LLMGW_ADMIN_KEY
   at construction time regardless of admin.enabled, but the Helm env
   snippet never listed it. Added it there, alongside a comment on the
   admin user explaining the env var is required either way. Fixed the
   two reversed above/below cross-references (kubernetes.yaml:90/:115)
   and reworded the misleading "self-contained" comment on the inline
   admin user. Verified with a throwaway round-trip test covering: no env
   vars set fails, every documented env var set succeeds (admin.enabled
   staying false as committed), and — the actual bug — every var set
   EXCEPT LLMGW_ADMIN_KEY still fails even with admin disabled.

2. README: Admin section's "(disabled still 404s)" now points at the
   same 404/passthroughUnknown wording the section already uses above it.
   Caching section's "block absent entirely is a construction error" now
   says "not enabled" — auth.go's actual check is !cfg.Cache.Enabled,
   which is identically true whether the block is omitted or present with
   enabled: false.

3. integration/mock/main.go: added POST /flaky/reset (handleFlakyReset),
   reachable through the "flaky" provider's own native passthrough route,
   resetting the atomic hit counter on demand. TestRetryFlakyRecovers now
   calls it at the start of the test, removing the make integration-keep
   re-run trap the old doc comment warned about instead of fixing.
   dynamic.yml.tmpl's stale "test 8" comment now reads "task 5".

4. integration_test.go: added redisKeys (docker compose exec redis-cli
   KEYS, mirroring flushRedis's own pattern) and a
   TestResponseCache assertion that redis holds at least one
   llmgw:cache:* key immediately after the miss request.

go test ./... -count=1, gofmt, vet, make yaegi-check all pass; make
integration exits 0 across two fresh full runs.
2026-08-20 20:30:08 +01:00
lukaszraczylo 2fe71c25eb docs: v0.2 features
README, examples, and a comment update covering v0.2's four features:

- README: new Caching section (key semantics incl. requestedModel +
  endpoint, hit/miss header, count-requests-free-tokens, group override,
  redis-required) and Admin section (enable flag, admin:true users,
  browser key-entry flow, sessionStorage, what's exposed/redacted,
  poll-counts-against-quota, unauthenticated-shell). Retry and Image/audio
  sections already existed from T3/T4; left as-is. Config reference gains
  cache/admin rows at the top level, GroupConfig.cache, UserConfig.admin,
  and full CacheConfig/AdminConfig tables. Known limitations gains
  images/audio-not-cost-accounted and cache-shares-entries-across-users.
- examples/kubernetes.yaml: adds cache{enabled:true,ttl:5m} (this example
  already configures redis) and admin{enabled:false} with a comment on
  enabling it, plus an inline admin:true user gated behind that flag.
  Round-trips through New() cleanly (verified with a throwaway test).
- examples/docker-compose.yml: adds retry, cache (left disabled — this
  minimal demo runs no Redis), and admin (left disabled, with a comment
  on enabling it). Round-trips through New() cleanly (verified the same
  way).
- admin.go: pluginVersion's doc comment now says to keep it in sync with
  the release tag.
2026-08-20 20:15:41 +01:00
lukaszraczylo 63459b0c24 fix: v0.2 review sweep
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.
2026-08-20 20:15:06 +01:00
lukaszraczylo 25578fb282 test: v0.2 integration coverage
Extends the integration suite (task 5) to exercise retry, cache, images/audio, and admin under real Traefik+Yaegi:

- mock/main.go: a /flaky/v1/chat/completions endpoint failing the first
  request per process with 503 then succeeding (atomic counter, response
  carries mock_attempt for verification); /v1/images/generations returning
  a canned OpenAI images response; /v1/audio/speech returning binary bytes
  under audio/mpeg; gemini mode's :predict returning canned Imagen
  predictions.
- dynamic.yml.tmpl: retry{enabled:true,attempts:1}, cache{enabled:true,
  ttl:2m}, admin{enabled:true}; a flaky provider pointed at the new mock
  endpoint; an admin:true inline user; alice and admin both given a
  generous-but-real per-user limits block so their own request counters
  are trackable via the usage API.
- integration_test.go: TestRetryFlakyRecovers (client 200, exactly two
  upstream hits proven via mock_attempt), TestResponseCache (miss then
  hit, tokensPerDay unchanged across the hit via the admin usage API),
  TestImagesGenerations (openai native forward + gemini Imagen
  translation), TestAudioSpeech (binary round trip, Content-Type
  forwarded), TestAdminDashboard (unauthenticated shell, admin vs
  non-admin gate, usage reflecting this suite's own traffic).

make integration exits 0 (verified across multiple fresh runs).
2026-08-20 20:14:38 +01:00
lukaszraczylo 33bd8bc2cc fix(admin): browser key entry, batched usage reads, hardened responses
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.
2026-08-20 19:46:35 +01:00
lukaszraczylo 547b61f5a9 feat: read-only admin dashboard and JSON API
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.
2026-08-20 19:26:22 +01:00
lukaszraczylo e23da896db fix(media): strip alias key on audio speech path 2026-08-20 19:06:04 +01:00
lukaszraczylo 2c8a32c77a fix(media): rewrite prefixed alias in multipart replay, strip alias key, multipart validation 2026-08-20 19:03:54 +01:00
lukaszraczylo 93b289c1c8 feat: unified image and audio endpoints with gemini image translation 2026-08-20 18:48:02 +01:00
lukaszraczylo 9b3e3ff930 fix(cache): alias+endpoint key material, bounded capture, outage latch 2026-08-20 18:22:44 +01:00
lukaszraczylo 06792d9798 feat: opt-in redis response cache for unified non-streaming routes 2026-08-20 18:05:07 +01:00
lukaszraczylo 722e6f3d29 fix(retry): retries-after-first semantics, ctx-aware backoff, hardened classification
Critical: attempts now counts retries performed AFTER the first try
(default 1 -> two tries total), not the total try count. The prior
reading made retry:{enabled:true}'s own documented default perform zero
retries. (*retryPolicy).totalTries() is the single conversion point,
clamping any attempts<1 to 1 so do()'s loop bound can never compute <=0
and skip calling call() entirely (no (nil,nil) return path).

Backoff waits are now ctx-aware: sleepFn func(time.Duration) is replaced
by waitFn func(context.Context, time.Duration) bool, whose production
implementation (defaultWaitFn) selects between time.After(d) and
ctx.Done(). A context canceled mid-wait aborts the remaining retries
immediately instead of blocking out the full backoff.

Also: classify a request-build failure (invalid method/URL) as
non-transient via errRequestBuildFailed, since retrying an identically
malformed request only wastes attempts; drop the drain cap to a
dedicated 64KiB constant instead of the 32MiB response cap; fix a
t.Fatal call from a non-test goroutine in the mid-stream-death test;
drop the misleading omitempty on Config.Retry (a struct value is never
'empty' to encoding/json); document the Retry-After >2s-falls-back-to-
backoff behavior and cover it with a direct table test.

README: add the retry config table, a RetryConfig field reference, and
a Retry section documenting the semantics. examples/kubernetes.yaml
gains a retry block alongside its other optional sections.

Gate: go test -race, gofmt, vet, yaegi-check, golangci-lint (incl a
fieldalignment fix on two new test structs) all clean. 295 subtests
pass, 0 failed. integration/ untouched.
2026-08-20 17:34:52 +01:00
lukaszraczylo 34968e27b2 feat: same-provider retry for transient upstream failures
Adds spec §1's opt-in retry engine: RetryConfig (top-level, zero value
disabled) and retryPolicy (retry.go), wired into upstreamJSON — the
single HTTP chokepoint every adapter's chat/embeddings/listModels call
already funneled through. A transient failure (net error, 429, 5xx)
retries up to Attempts times with exponential backoff capped at 2s,
honoring a qualifying Retry-After header on 429, draining and closing
each failed attempt's body before retrying. Never retries a non-429 4xx,
a translate error, or after context cancellation.

Because upstreamJSON returns before any adapter forwards a byte to the
client (forwardStream/forwardJSON run only after it returns), the
'zero bytes reached client' safety rule and the 'streaming retries only
the initial exchange' rule both hold by construction — no adapter
streaming logic needed to change.

buildAdapters(cfg) keeps its v0.1 signature: it derives the retryPolicy
from cfg.Retry internally rather than taking it as a parameter, so
every existing direct call (15+ across provider_openai_test.go) keeps
compiling unmodified. Every v0.1 test passes byte-identically, without
edits.
2026-08-20 17:11:40 +01:00
lukaszraczylo a280ee8aee test(integration): non-colliding provider name for real-upstream smoke
The upstream gateway's model ids carry a uni/ prefix; a provider named
uni made the plugin strip it as a provider prefix. Renamed to real and
listed the full slash-bearing id, exercising bare-id resolution.
2026-08-20 16:05:35 +01:00
lukaszraczylo 90bb837a5d fix: escape request path in auth event logs
logAuthEvent (logger.go) formatted r.URL.Path with %s in both the
failure and success log lines. An unauthenticated client controls that
path and can embed a raw newline (%0A) in it, letting a single request
forge additional, attacker-chosen log lines. Switched both to %q, which
escapes control characters (newlines included) while still rendering
the path as a quoted string.

No test changes needed: both existing tests (llmgateway_test.go) assert
via strings.Contains on the bare path substring, which %q's quoted
output still contains.

Gates: go test ./... -count=1 green, gofmt -l . empty, go vet clean.
2026-08-20 15:06:34 +01:00
lukaszraczylo c9ae14adf7 docs: spec limit semantics, pricing caveats, manifest hedge
I2 (controller ruling): docs/superpowers/specs/2026-08-19-traefik-llmgateway-design.md
described limits as "user overrides group" / "user limit if set, else
group limit, else unlimited" — stale relative to the actual, already-correct
implementation (buildLimitScopes/checkAndCount evaluate every configured
scope and return the first violation). Amended §2 and §7 to state both
scopes are enforced together and the tighter bound wins.

I4 (manifest hedge): .traefik.yml's groups.default was a bare "{}",
the shape empirically reproduced as a decode failure outside this
repo's own (necessarily simplified) yaegi-check harness. Changed to
"providers: []" — same zero semantics, no longer a bare empty object.
manifest_test.go's JSON mirror and drift-check token updated to match;
make yaegi-check still passes (its YAML-subset parser already handles
an inline "[]" under a nested map key).

M9 (pricing provenance): pricing.go's built-in table comment replaced
"verify before release" with a real source note (each provider's own
published list prices, recorded 2026-08, approximate). README's limits
section gained the matching caveat and points to the "pricing" config
override for billing-grade accuracy.

Doc nits: README's Kubernetes section now says the example manifest
applies cleanly once Traefik's CRDs (Middleware, IngressRoute) are
installed, rather than claiming kubectl apply -f works unconditionally.
README's Security notes section no longer claims "no auth-event logging
at all" (see the paired fix: commit) — it now describes what is logged
and that key material never appears.

This commit and its sibling fix: commit are the final wave closing out
the whole-branch review findings for docs/superpowers/sdd/2026-08-20-
traefik-llmgateway.

Gates: go test ./... -count=1 (-race) green, gofmt -l . empty, go vet
clean, make yaegi-check OK, golangci-lint 0 issues.
2026-08-20 14:59:57 +01:00
lukaszraczylo 4b849fd6c7 fix: force include_usage, auth event logging, credential-strip coverage
C1 (quota bypass): a client sending stream_options.include_usage=false
suppressed upstream usage reporting and escaped every token/cost budget,
since the non-streaming T12 estimate never covers a streaming response.
chatCompletion (provider_openai.go) now always forces include_usage=true
on the outgoing request, merged into a copy of the client's own
stream_options map so the caller's map is never mutated in place.
clientAskedUsage is derived from the client's own value via isTruthy, so
the usage-only chunk is still suppressed for a client that never asked
for it.

I1 (vacuous security tests): the X-Api-Key credential-strip set had zero
effective coverage — existing tests either never sent x-api-key, or
targeted anthropic, whose injectAuth overwrites x-api-key regardless of
whether the strip ran. Added one openai-passthrough and one MCP-proxy
test that authenticate via x-api-key itself and assert it arrives empty
upstream. Mutation-checked: both fail when "X-Api-Key": true is removed
from gatewayCredentialHeaders (verified, then restored).

I3 (auth-event logging): added Gateway.logAuthEvent (logger.go), called
at all six authStore.identify call sites (llmgateway.go's ServeHTTP,
routes_unified.go's handleModels). A failed attempt logs method, path,
and remote address; a success logs the resolved user's name and route.
Neither line ever includes the presented API key.

M2 (usage.estimated): runUnified now reads result.estimated and logs the
estimate explicitly when a non-streaming response's usage falls back to
the body-size heuristic, making the field genuinely read — removes the
stale //nolint:unused on limits.go's usage struct.

M8 (gosec consistency): secrets.go's file: secret path read now carries
the same "#nosec G304 -- operator-supplied file path from middleware
config" annotation as users_file.go's equivalent read.

Doc nit: mcp_a2a.go's validateTargetURLs comment now also names the
dot-only ("."/"..") rule validateConfigName enforces, not just character
set and reserved names.

Gates: go test ./... -count=1 (-race) green, gofmt -l . empty, go vet
clean, make yaegi-check OK, golangci-lint (gosec+staticcheck+unused+
errcheck+govet, global config) 0 issues.
2026-08-20 14:59:21 +01:00
lukaszraczylo 5a11cc85be docs: correct security claims, pipeline diagram, k8s example completeness
Fixes from review, verified against source before rewriting:

- README.md Security notes: "auth events are logged" was false — grepped
  auth.go, zero logging calls exist anywhere in it. A lookup miss is a
  plain 401 envelope with no log line at all, not "logged without key
  material". Also corrected the adjacent providerHTTPError claim: the
  struct retains the upstream response body as a field (surfaced
  deliberately to the client under error.upstream); only its Error()
  string omits the body, and nothing in this codebase currently logs a
  providerHTTPError via a bare %v anyway.
- README.md mermaid diagram: the previous version showed a single global
  Auth gate ahead of routing. ServeHTTP (llmgateway.go) actually dispatches
  on path+method first and calls identify() separately inside each
  recognized-route branch; an unrecognized route (passthroughUnknown/404
  fallback) never calls identify() at all. Redrawn Route -> per-branch
  Auth, with the unauthenticated fallback branching directly off Route.
  Also fixed the 401 node's error type label from the nonexistent
  "invalid_api_key" to the actual envelope type, "authentication_error"
  (llmgateway.go, routes_unified.go). Re-rendered with mmdc before and
  after the fix.
- README.md + examples/kubernetes.yaml: narrowed the bare-{} empty-group
  claim. Re-verified this session with an isolated, minimal repro
  (Traefik file provider only, no docker-compose configs: mechanism
  involved) that a bare `default: {}` still fails plugin construction
  with the file provider specifically: "expected a map, got 'string'".
  Scoped the claim to what is actually verified (file provider, twice)
  and explicitly flagged Docker/ECS label providers and the Kubernetes
  CRD provider as untested, rather than asserting the file provider is
  unaffected or that the CRD provider shares its decode path — the
  latter was an unverified inference in the prior text, now hedged.
- examples/kubernetes.yaml: the IngressRoute referenced an undefined
  Service (llmgateway-dummy-backend), so the manifest did not actually
  apply as advertised. Added the missing Deployment + Service (traefik/
  whoami, the same image already exercised end-to-end in
  examples/docker-compose.yml this session). Re-validated: 6-document
  YAML parses, Middleware's plugin config still round-trips through the
  real Config struct and New() (throwaway test, deleted after).
- README.md Known limitations: dropped the standalone path-traversal
  bullet — it duplicated content already stated in the "request rejected
  before it reaches a provider" bullet immediately above it.

NOTE for the reviewer: the ruling on the bare-{} finding stated "file
provider observed working" and named Docker/ECS label providers as the
affected ones. I re-tested this directly (twice, including a standalone
repro outside the compose demo) and the file provider fails on a bare
{} exactly as originally reported — I could not reproduce "file provider
unaffected" and have not tested the label providers at all. Narrowed the
doc wording to only what I verified rather than asserting either
provider's behaviour without evidence; flagging the discrepancy in case
the review had independent grounds I don't have visibility into.

Gates: go test ./... -count=1, gofmt -l ., go vet ./..., make
yaegi-check — all clean. Also re-ran go test -race, golangci-lint (incl.
gosec), and fieldalignment, matching the standard this task already set;
all clean.
2026-08-20 14:30:32 +01:00
lukaszraczylo 3f49cda42c fix: reject dot-only config names
configNamePattern (^[a-zA-Z0-9._-]+$) permits "." and ".." as valid
provider/mcpServers/agents names, since a bare dot is in the allowed
character class. Both names read as directory-traversal segments once
embedded as a path segment in the gateway's own routes
(passthroughRoute's "/{provider}/*", targetRoute's "/mcp/{name}/*" and
"/a2a/{name}/*"). Nothing in this package's current path handling
mis-resolves them, but disallowing them at construction is cheap and
removes any future dependence on that staying true.

- providers.go: add dotOnlyConfigNames ({".", ".."}), checked in
  validateConfigName between the character-class check and the
  reserved-name check.
- provider_openai_test.go: extend TestValidateConfigName with "." and
  ".." cases (rejected) across provider/mcpServers/agents kinds, plus
  "..." (accepted — the rule is an exact match on "." and "..", not "any
  dots-only string").

Gates: go test ./... -count=1, go test ./... -race -count=1, gofmt -l .,
go vet ./..., make yaegi-check, golangci-lint run ./..., golangci-lint
run --enable=gosec ./..., fieldalignment ./... — all clean.
2026-08-20 14:29:55 +01:00
lukaszraczylo 08dca6ad98 docs: README, kubernetes and compose examples, MIT license
- README.md: what-it-is, quickstart (localPlugins today, catalog form
  once published), full config reference table for every Config/
  ProviderConfig/GroupConfig/LimitsConfig/UsersConfig/UserConfig/
  RedisConfig/ModelPricing/TargetConfig/AgentConfig field, model routing,
  limits and accounting semantics, unified vs passthrough differences,
  MCP/A2A, security notes, known limitations (streaming buffering under
  Yaegi, quota-on-reject nuances, identity-encoding, path-traversal
  rejects), Kubernetes deployment notes, and development/catalog-
  submission status. Every claim grounded against the actual source read
  this session; two draft claims (user-limits "replace" group limits, an
  unqualified "go-redis is documented as unsuitable in resp.go/
  providers.go") were caught and corrected against the real
  buildLimitScopes semantics and an actual grep before committing.
- examples/kubernetes.yaml: Secret (users.json) + Middleware CR +
  IngressRoute + a Helm values snippet. The Middleware's plugin config
  round-trips through the real Config struct and New() (verified via a
  throwaway, since-deleted test), not just YAML-syntax-checked.
- examples/docker-compose.yml: minimal localPlugins-based demo (traefik +
  whoami + inline file-provider config via compose's inline configs.content).
  Brought up and exercised end-to-end against real OpenAI this session:
  auth 401/200, /v1/models, /v1/chat/completions (upstream 401 envelope
  with a fake key, proving passthrough+key-injection+wrapping all work),
  and the unknown-route 404 envelope. Found and fixed a real bug in the
  process: a bare "{}" empty group value fails Traefik's own dynamic-
  config decode with "expected a map, got 'string'"; fixed to an
  explicit empty list and documented the gotcha in both example files and
  the README.
- LICENSE: MIT, copyright 2026 Lukasz Raczylo.
2026-08-20 14:12:05 +01:00
lukaszraczylo ae4d6bab96 fix: config validation hardening and integration polish
- providers.go: validate provider names against ^[a-zA-Z0-9._-]+$ and
  reject reserved names (v1, mcp, a2a) that would shadow gateway routes;
  reject nil ProviderConfig map values at construction instead of
  panicking.
- mcp_a2a.go: apply the same name-pattern and reserved-name validation to
  mcpServers/agents keys, and reject nil TargetConfig/AgentConfig map
  values at construction.
- auth.go: reject nil GroupConfig map values at construction.
- provider_openai_test.go, mcp_a2a_test.go, auth_test.go: table-driven
  constructor-error coverage for the above.
- integration/integration_test.go: point the package doc at
  `make integration-up`/`integration-keep` instead of a bare
  `docker compose up -d --build`, which skips the dynamic.yml.tmpl
  render step and can run against a stale or absent dynamic.yml; add
  max_tokens to the gemini chat request so translate_gemini.go's
  hasMaxTokens=true branch is actually exercised under real Yaegi.
- translate_gemini.go: soften the max_tokens comment's verification claim
  to match what the integration suite actually now exercises.
2026-08-20 13:57:56 +01:00