Commit Graph
260 Commits
Author SHA1 Message Date
lukaszraczylo 66fa0b56ea feat(admin): MCP/agent target visibility
Add per-target request accounting for the MCP/A2A proxy path and a
new admin endpoint to surface it.

- limits.go: countTargetRequest increments one target's req counters
  at min/hour/day/month in a single storeIncrMulti call — pure
  accounting, no limits, mirroring account()'s own contract.
  targetUsage batches the read side the same way currentUsage does
  for user/group scopes.
- mcp_a2a.go: handleTargetProxy attributes every admitted proxied
  request to its target's own scope, kind "mcp" or "agent"
  (scopeKindAgent — deliberately not targetKindAgent's "a2a" routing
  convention). kind is embedded in the counter key (windowKey), so a
  target can never collide with a user/group/total scope of the same
  id, whatever any of them are named.
- admin.go: new GET /admin/api/targets, admin-gated and
  stats-excluded exactly like the other three /admin/api/* routes.
  Access lists are computed via the SAME matchesGlob call
  group.allowsMCP/allowsAgent themselves use, so the panel can never
  disagree with enforcement; a target every group can reach collapses
  to an omitted access list ("empty meaning all").

No new config surface: target scopes carry no configurable limits
this round.

Tests: TestTargetScopeKind (mapping), TestHandleTargetProxy_
PerTargetCounters_AttributedAndCollisionSafe (table-driven over
mcp/a2a, proves counters land on the right scope and never collide
with a same-named user), TestAdminTargets_* (gating tables extended,
shape/sorting/URL-sanitization/access-list/counter assertions).
2026-08-21 18:24:32 +01:00
lukaszraczylo 1183b6fa3c feat(limits): unconditional usage accounting
buildLimitScopes previously omitted a user or group scope entirely
whenever that entity had no configured Limits, so a limit-less
user/group never accumulated ANY usage counters. Root-caused live:
the admin dashboard's total kept climbing while every per-user row
stayed zero until an operator added phantom, unreachable
requestsPerDay limits everywhere just to make counters build at all.

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

Update stale doc comments coupling accounting to configured limits
(evaluateScope, the integration alice fixture, routes_media_test.go,
routes_unified_test.go, admin_test.go's admin1 assertion) and add:
- TestBuildLimitScopes_AlwaysBuildsBothScopes: table-driven over
  every nil/non-nil user/group limits combination.
- TestAdminUsage_LimitlessUserAndGroup_AccumulateRealTraffic:
  end-to-end regression proving a limit-less user and group
  accumulate real request/token counters visible via
  GET /admin/api/usage.
2026-08-21 18:13:46 +01:00
lukaszraczylo 90545df588 docs: drop reference to local design-doc path v0.2.0 2026-08-21 13:58:55 +01:00
lukaszraczylo 0530a4a2ff chore: ignore local docs/superpowers workspace 2026-08-21 13:58:11 +01:00
lukaszraczylo 87faa9e096 chore(lint): suppress gosec G704 on operator-configured redis dial 2026-08-21 13:46:28 +01:00
lukaszraczylo 1c511085d8 chore(admin): regenerate baked admin assets 2026-08-21 13:41:32 +01:00
lukaszraczylo b081055aaa fix(admin): wire-level access tests, MCP/agent UI, glob-safe chips
Review fix round on the group-access-display feature (Should-Fix + folded
items):

1. TestAdminUsage_GroupAccessLists now asserts at the raw-JSON level, not
   just the typed adminUsageResponse struct: decodes rows into
   map[string]json.RawMessage and checks the four access-list keys are
   ENTIRELY ABSENT on user/total rows and an unrestricted group, PRESENT
   with exact values on a restricted group. A typed-struct decode alone
   cannot prove omitempty is doing its job -- a present-but-null field
   decodes into the same nil []string a genuinely absent key would.
   Verified by temporarily stripping the omitempty tags: the new
   assertions fail (four keys read back 'null'); the old struct-only
   assertions stayed green throughout, confirming the gap the review
   flagged.

2. UsageView.vue's Access block now renders MCP servers and Agents too,
   same Badge treatment as Providers, with muted 'All MCP servers'/
   'All agents' fallbacks -- GroupConfig carries all four lists
   (llmgateway.go), so the display now matches the payload instead of
   dropping two of the four fields silently.

3. A configured Models entry containing *, ?, or [ (path.Match's own
   special characters -- auth.go's matchesGlob) is a pattern, not a
   routable id: it now renders as a plain, non-copyable Badge
   (font-mono, secondary) instead of the copyable ModelChip, so a caller
   can no longer copy a glob pattern into a request's model field
   thinking it's a real id. Every Access label (Providers/Models/MCP
   servers/Agents) also gets a title attribute (one shared GLOB_HINT
   constant) making the glob-match semantics discoverable, since all
   four lists are glob-matched, not just Models.

4. groupSummary's providers/models/mcpServers/agents are now independent
   copies (cloneStringSlice), not aliases of the live group.* slices
   authorization reads on every request -- structurally prevents a
   future display-side sort/mutation from ever reordering or corrupting
   enforcement data. slices.Clone was tried first and reverted: it broke
   make yaegi-check (yaegi's stdlib symbol table does not resolve the
   generic slices package's type parameters under interpretation), so
   cloneStringSlice uses plain make/copy builtins instead -- verified
   yaegi-check passes with this version.

5. README.md's usage endpoint section: fixed the stale 'Overview and
   Usage views' reference (now 'Providers', matching the earlier tab
   rename) and added one line naming the new providers/models/
   mcpServers/agents group-access fields, matching the section's
   existing terseness.

6. Renamed the 'unrestricted group carries empty/omitted lists' subtest
   to 'empty or omitted' -- the slash broke -run subtest path matching.

7. The Access block now uses the same dl/dt/dd markup as the sibling
   stat grid immediately below it, instead of a bespoke div/p structure
   -- one convention per panel.
2026-08-21 13:41:11 +01:00
lukaszraczylo d99b959ee6 chore(admin): regenerate baked admin assets 2026-08-21 13:22:48 +01:00
lukaszraczylo 03bc67a5f0 feat(admin): expose group provider/model access
GET /admin/api/usage's group rows now carry the group's configured
Providers/Models/MCPServers/Agents (GroupConfig, llmgateway.go), echoed
exactly as configured -- never resolved/expanded to the full catalog
server-side. Empty/unset stays omitted (omitempty), matching
GroupConfig's own JSON convention and group.allowsX's empty-means-all
semantics (auth.go): the dashboard can tell 'restricted to these N' apart
from 'unrestricted' without the payload growing with catalog size.

groupSummary (auth.go) gains providers/models/mcpServers/agents, carried
through from authStore.snapshot unchanged. adminUsageEntryView (admin.go)
gains the matching JSON fields, populated only for group entries -- a
user or the total scope row never carries them, mirroring GroupName's
own user-only convention.

The Usage view's group accordion (webui/UsageView.vue) renders an Access
block above the existing stat grid: Badge chips for explicit providers,
ModelChip for explicit models (both shadcn/existing components, no
hand-rolled chips), or muted 'All providers'/'All models' text when
unrestricted. Display-only this round -- not wired into the user/group
search filter. types/api.ts's AdminUsageEntryView mirrors the new
optional fields.

Tests: admin_test.go's new table-driven TestAdminUsage_GroupAccessLists
covers a restricted group's exact lists, an unrestricted group's
empty/omitted lists, and that a user/total row never carries them.
2026-08-21 13:21:58 +01:00
lukaszraczylo bb4fa4420e refactor(webui): rename Overview tab to Providers
Mechanical rename: OverviewView.vue -> ProvidersView.vue (file, component
import, tab label/value in App.vue), plus doc-comment/README references
across webui/ that named the view by its old tab label.

The backend GET /admin/api/overview endpoint, adminOverviewResponse type,
and the frontend AdminOverviewResponse/dashboard.overview field all keep
their original names unchanged (API surface) -- only the UI tab label and
the component/file that renders it changed. No behavior or layout change
to the view itself.
2026-08-21 13:17:11 +01:00
lukaszraczylo 22006c5e25 chore(admin): regenerate baked admin assets 2026-08-21 13:03:12 +01:00
lukaszraczylo 5408b8e895 fix(webui): address auth-gate review findings
Explicit refocus. Add a watch(() => auth.error, ...) that calls the
same focusKeyInput() helper onMounted already used. Previously,
refocus-after-failure only worked through an undocumented chain
(auth.submit() optimistically flips isAuthenticated, App.vue unmounts
AuthGate, a 401 calls auth.reject(), isAuthenticated flips back,
AuthGate remounts, onMounted refires). That chain still exists and
still drives the shake retrigger; a comment above the Input now says
so explicitly, so a future change to optimistic auth does not break
either behavior silently.

Security disclosure order. Move the three-item checklist above the
form, so the claims sit in the reading path before the key field
instead of below the submit button. Add a visually hidden `<h2
id="authgate-security-heading">` and tie the `<ul>` to it via
aria-labelledby, giving screen-reader users a label the list did not
have before. Wording and text-xs sizing are unchanged.

Invalid-state wiring. Add :aria-invalid and :aria-describedby to the
Input, pointing at a new id="authgate-error" on the Alert, closing the
loop between the input and its own error message.

Typography hierarchy. CardTitle grows to text-lg font-semibold; the
"LLM Gateway" wordmark drops to a muted-foreground text-xs eyebrow, so
the title now reads as the dominant line instead of losing to the
wordmark's former text-sm font-semibold.

Drop text-base from the Input's class list — Input.vue already sets
text-base (overridden by its own md:text-sm at >=768px); the local
copy did nothing but restate the default. h-10/pr-10 stay.
2026-08-21 13:03:01 +01:00
lukaszraczylo eee4ada0c4 chore(admin): regenerate baked admin assets 2026-08-21 12:47:45 +01:00
lukaszraczylo 49c9325c9f feat(webui): redesign admin key gate
Add a circular key-icon badge and a small "LLM Gateway" wordmark, so the
screen reads as this product's door and not a generic prompt.

Restructure the dense security paragraph into a short description plus a
three-item muted checklist. Each claim keeps its original strength: the
key stays in this tab's session storage, the browser never writes it to
disk, and requests go only to this page's own /admin/api/* routes.

Add a show/hide toggle for the key input. It reuses the ghost
icon-button composition SearchInput.vue already uses for its clear
button. Autofocus the input on mount. Disable submit while the trimmed
input is empty.

Add a brief shake on the key input when auth.error is set. The
animation respects prefers-reduced-motion and lives in a scoped
<style> block local to this file — the first one in webui/src/components,
added because no Tailwind-core or tw-animate-css keyframe reads as
"rejected".

Extend the header in App.vue with a GitHub repository link, per an
operator scope addition mid-task: an icon-only ghost button that opens
github.com/lukaszraczylo/traefik-llmgateway in a new tab. The link sits
in App.vue's header, above the gate/dashboard switch, so it renders on
every screen including the auth gate. Installs
@fortawesome/free-brands-svg-icons, pinned like the other fontawesome
packages, for the faGithub icon.

auth.submit() semantics, dashboard.refresh() timing, and the backend
stay unchanged.
2026-08-21 12:47:31 +01:00
lukaszraczylo 9d5cc12b10 chore(admin): regenerate baked admin assets 2026-08-21 12:17:14 +01:00
lukaszraczylo 9de2204752 chore(webui): drop dead clear() from useSearchQuery, add spec, doc composables
- useSearchQuery.ts: remove clear() — zero callers, since SearchInput's
  clear button already clears by emitting update:modelValue('') through
  v-model, no separate imperative clear needed.
- Add composables/useSearchQuery.spec.ts (3 cases: normalized trims,
  normalized lowercases, hasQuery false for whitespace-only query).
- README.md Structure section: document src/composables/ (useThemeColors,
  useSearchQuery) and mention the new shared SearchInput component
  alongside the existing components/lib entries.
2026-08-21 12:17:10 +01:00
lukaszraczylo 3bce93971d chore(admin): regenerate baked admin assets 2026-08-21 12:17:07 +01:00
lukaszraczylo 24809ed25a fix(webui): shared SearchInput/useSearchQuery, correct expand rationale
Address review of the user/group search filter (feat/user-search):

- Correct the "group name match does not auto-expand" rationale in
  UsageView.vue and lib/search-expand.ts. The prior comment claimed
  Overview has no provider-name search — false: OverviewView's
  providerMatches checks each model's full ROUTABLE id
  (`${provider}/${model}`, lib/format.ts's routableModelId), so a
  provider-name query does match and auto-expand there. The actual
  reason Usage withholds auto-expand from a name-only match: a group's
  name is already legible on its collapsed trigger row, so expanding
  every name match too would dump full member rosters for no reason;
  a member-only match has no such shortcut and still needs the reveal.
  Behavior is unchanged — only the (now accurate) justification.

- Extract components/SearchInput.vue and composables/useSearchQuery.ts.
  OverviewView's model search, UsageView's user/group search, and
  ChartsView's scope-picker search were three ~22-line copies of the
  same icon+Input+clear-button markup, plus three copies of the
  query/normalized/hasQuery/clear quartet. All three views now consume
  the shared component/composable. SearchInput's clear button is a real
  shadcn Button (variant="ghost", size="icon-xs" — 24x24px) instead of a
  hand-rolled <button>, fixing a WCAG 2.2 SC 2.5.8 hit-target/
  focus-visible gap once instead of three times.

- ChartsView's search input: w-48 (clipped the 22-char placeholder) to
  w-56, matching the sibling SelectTrigger's width.

- UsageView.vue's visibleMembers now calls the exported, tested
  matchingMembersOfGroup helper instead of re-implementing the same
  filter inline with userMatches.

- Reworded the "shared with ChartsView.vue" claims on membersOfGroup
  (usage-search.ts) and in UsageView.vue's visibleMembers doc comment:
  ChartsView never calls membersOfGroup directly, only indirectly via
  groupMatches -> matchingMembersOfGroup.

- UsageView's groups/users empty-state messages now follow Overview's
  aliasEmptyMessage three-way pattern ("none configured" vs "no
  groups/users match ..." vs "none"), distinguishing nothing-configured
  from nothing-matches instead of collapsing both to the same text.

Gate unchanged: npm run build + npm test + vue-tsc, make admin-ui
(separate regen commit), go test ./... -count=1, gofmt/vet, make
yaegi-check.
2026-08-21 12:17:04 +01:00
lukaszraczylo 46537fa16a chore(admin): regenerate baked admin assets 2026-08-21 12:17:01 +01:00
lukaszraczylo d6b9879813 feat(webui): user and group search filter on usage and charts views
Add a "Filter users or groups" search input to UsageView, mirroring
OverviewView's model search (same Input component, icon, and
clear-button styling). Case-insensitive, trimmed substring match:

- A user matches when its own name contains the query.
- A group matches when its own name contains the query OR it has at
  least one matching member.

Groups accordion shows only matching groups, sorted through the
existing Groups toolbar unchanged. A group that matches only via a
member auto-expands (using the same tested override semantics as
Overview's provider search); a group matched by its own name is not
force-expanded, since the match is already visible on its collapsed
trigger row. The nested members table inside an expanded group shows
every member when the group matched by name, and only the matching
members when it matched via a member.

The flat Users table filters to matching users, plus every member of
a name-matched group. The Total card and ChartsView's chart rendering
are untouched by the filter.

ChartsView's scope picker (Select) gets the identical search input,
filtering the user/group option list it offers. "Total" always stays
in the list, and the currently selected scope is always kept in the
list even if a new query would otherwise hide it, so an active chart
is never torn down or re-labeled by narrowing the picker.

Generalize lib/provider-expand.ts into lib/search-expand.ts: the same
two-set (manuallyExpanded/manuallyCollapsed) auto-expand/override state
machine now drives both Overview's providers and Usage's groups, keyed
on generic string ids instead of provider names specifically.
OverviewView.vue is updated to the renamed
computeExpandedItems/toggleItemExpand exports; its own behavior is
unchanged. The moved spec (search-expand.spec.ts) keeps all 9 original
cases plus 2 new ones pinning the same semantics against non-provider
(group-shaped) ids.

Add lib/usage-search.ts with the shared user/group matching helpers
(userMatches, groupNameMatches, membersOfGroup,
matchingMembersOfGroup, groupMatches) so UsageView and ChartsView
share one matching implementation, with its own vitest spec.

UsageTable.vue gains an optional emptyMessage prop (threaded to
DataTable) so UsageView can show a "no users match ..."/"no groups
match ..." message under an active filter, matching Overview's own
alias-search empty-state pattern.

No backend/Go logic changes; filtering is client-side over the
already-polled GET /admin/api/usage response.
2026-08-21 12:16:57 +01:00
lukaszraczylo dded3a56fe test(integration): widen TestUsersFileHotReload deadline for VirtioFS lag
Docker Desktop VirtioFS propagation of a host write to the guest's
view of the bind-mounted users.json is not bounded by the plugin's
own 5s reload throttle. Direct measurement (host write + polled
guest-side read, independent of the plugin) showed convergence times
from under 1s up to 18s, and a captured real test run took ~26s end
to end, exceeding the prior 20s deadline. Raise it to 60s, more than
2x the worst value observed, and document the measurement so a future
investigator does not have to re-derive it.

users_file.go was verified correct for this failure class: it
re-opens and re-reads the users file by path on every reload cycle,
never caches by file descriptor or inode, which is also the correct
pattern for a Kubernetes ConfigMap/Secret atomic symlink-swap update.
No production code changed.
2026-08-21 11:50:58 +01:00
lukaszraczylo dc8663673b docs: mark project experimental PoC until version 1.0 2026-08-21 11:42:54 +01:00
lukaszraczylo 7f500ff803 fix(webui): shared sort header, coherent group sorting, real expand-state tests 2026-08-21 11:27:32 +01:00
lukaszraczylo b76351c566 chore(admin): regenerate baked admin assets
make admin-ui, reflecting the webui accordion/DataTable rework
(previous commit). admin_assets_gen.go: 946,771 bytes. Bundle:
index-*.js 370.45 kB (gzip 114.65 kB), ChartsView-*.js (lazy) 237.72 kB
(gzip 79.92 kB), index-*.css 49.95 kB (gzip 9.03 kB).
2026-08-21 11:13:28 +01:00
lukaszraczylo 60d66ca147 feat(webui): shadcn-vue accordion + sortable DataTable, full-width panel
Component-first admin-panel rework (operator directive), replacing
hand-rolled patterns with the actual shadcn-vue library components
rather than working around them.

- Providers (OverviewView) and Groups (UsageView) now render as a real
  shadcn-vue Accordion: trigger is the summary line, content holds the
  detail (base URL, last refresh/error, model chips for providers;
  full stat grid + member users for groups) — the earlier hand-rolled
  second-<TableRow> toggle is retired. lib/provider-expand.ts's tested
  two-set expand-state module is unchanged; only the template now
  adapts it to Accordion's type="multiple" v-model via a writable
  computed instead of driving a plain v-if.
- Usage view: expanding a group shows its member users' own usage rows
  — a client-side join on AdminUsageEntryView.groupName, which admin.go
  already sets per user (verified via admin_test.go's existing
  TestAdminUsage_MathAgainstSeededCounters; no backend change needed).
- New DataTable.vue: the shadcn-vue DataTable pattern
  (@tanstack/vue-table, pinned to ^8.21.3 — the version whose API
  matches the documented pattern; npm's default "latest" resolves to
  v9, a breaking rewrite with a different API) factored into one
  reusable, generic component so every real table (users usage,
  aliases, a group's nested member list) gets clickable sortable
  headers with asc/desc indicators for free. usage-columns.ts holds
  the shared ColumnDef set; UsageTable.vue now delegates to DataTable.
  Groups themselves cannot be a literal <table> (Accordion wraps
  content in a <div>, illegal inside <tbody>), so their own
  sortability is driven by the same columns headlessly, surfaced as a
  small header-button toolbar above the Accordion.
- Aliases table: alias id gets the ModelChip copy treatment (the alias
  name is the routable string); target stays plain text.
- App.vue: dropped the max-w-6xl container — the panel now uses the
  full viewport width with responsive gutters (px-4 sm:px-6 lg:px-8).
  Table.vue already wraps every table in its own overflow-x-auto
  container, so a wide table still scrolls itself, never the page.

shadcn-vue add accordion re-added @lucide/vue and required swapping
its Chevron icons for FontAwesome (webui/README.md's own documented
trap); components.json's iconLibrary/font keys stayed absent
(verified main.css byte-identical after the add).

Browser-verified against the real integration stack (real /admin over
the Yaegi plugin, strict CSP incl. style-src 'self'): provider/group
accordion expand, search auto-expand through the new Accordion
adapter, sort toggles on aliases/users/groups-toolbar/nested-member
tables, alias copy-to-clipboard, full-width at 1920px. Zero console
errors (one unrelated MetaMask extension log).
2026-08-21 11:12:59 +01:00
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