mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-20 05:56:56 +00:00
d70e5fc1df77f46b4ec0077ab519ef0ad7de5efd
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d70e5fc1df |
fix(auth): decode rest before matching the passthrough path allowlist
Close the %2f allowlist bypass (security review finding 3, round 3,
2026-08-22): allowsPassthroughPath and hasTraversalSegment (routes_
passthrough.go) reasoned about rest asymmetrically -- the traversal check
decodes it first, the allowlist matcher ran path.Match against the raw
escaped form. path.Match("v1/*", "v1/chat%2fcompletions") reports true
(one escaped segment, no literal "/" to split it), while the decoded
equivalent, "v1/chat/completions", is two segments and does not match
"v1/*". A group restricted to PassthroughPaths: ["v1/*"] therefore let
"/openai/v1/fine_tuning%2Fjobs" through -- a path an upstream that
itself decodes %2F would read as a different, unauthorized endpoint.
allowsPassthroughPath now url.PathUnescape's rest before matching -- the
same decode hasTraversalSegment already applies -- and denies a rest
that fails to unescape at all, mirroring hasTraversalSegment's own
fail-closed rule for malformed encoding. The empty-pattern fast path
(matchesGlob's own empty-means-all contract) is checked first, before
any decoding: a group with no PassthroughPaths configured -- every live
group today -- keeps allowing every request exactly as before, malformed
encoding included; hasTraversalSegment (unconditional, later in
handlePassthrough) remains the gate for that case.
Tests: a direct unit test proving the decoded three-segment form is
denied (both via the encoded and literal spellings), a single-segment
encoded value still matches, malformed encoding denies for a restricted
group and is unaffected for an unrestricted one, and an end-to-end
ServeHTTP test reproducing the exact finding scenario.
|
||
|
|
a221b4d2eb |
fix(passthrough): fail closed on a model peek that never closes the object
Close the residual gap in the duplicate-"model" fix (security review
finding 2, round 3, coordinator ruling): round 2 made a duplicate
top-level "model" key WITHIN the 64KiB peek window fail closed, but a
client can still pad the body so a second "model" sits just PAST
maxModelPeekBytes -- `{"model":"cheap", <64KiB padding>,
"model":"expensive"}`. The peek reports "cheap" (the only key it saw),
the body is forwarded byte-for-byte, and the upstream (last-wins on
every mainstream JSON parser) executes "expensive".
scanTopLevelModel now also reports whether it actually confirmed the
top-level object's closing '}' within the scanned window (closed).
peekPassthroughModel combines that with its own knowledge of whether the
read hit the byte cap (more of the body remains unread) to report
truncated: true only when both hold, meaning a later duplicate cannot be
ruled out. handlePassthrough denies (403, a message distinct from "model
could not be determined") whenever truncated is true, checked before
even looking at the found model -- a found model under truncation can
never be trusted.
Scoped exactly as ruled: only affects a group with a non-empty Models
list (grp.hasModelRestriction) whose body is large enough to hit
maxModelPeekBytes -- an unrestricted group never calls
peekPassthroughModel at all, unchanged from round 2 and pinned by a new
test. No live group is model-restricted today. The unified /v1/* route
remains the supported path for a legitimately large restricted-group
body.
Tests: the coordinator's own exploit body end-to-end (403, upstream
never called); the general non-duplicate case (any restricted-group body
that never closes within the window denies, not just a provably
duplicate one); the unrestricted-group-unaffected case explicitly, using
the identical exploit body; and closed's own truncation-tracking table
against scanTopLevelModel directly. One pre-existing test
(TestHandlePassthrough_BodyIntegrity_ByteIdentical_LargeBody) used a
restricted group incidentally while testing an unrelated property (byte-
for-byte forwarding) -- switched to an unrestricted group so it tests
only what its name says.
|
||
|
|
f736b231a3 |
fix(routes): admit before decode, bound in-flight bodies, narrow unified cap
Close a HIGH-severity OOM path on the unified and media JSON routes (security review finding 1). Three parts: - checkAndCount now runs via a new admitRequest helper BEFORE the request body is read at all, on both routes_unified.go's runUnified and every routes_media.go handler. The rate-limit scopes need only the already-resolved user/group, so a caller already over their requestsPerMinute is refused without paying for the read+decode of a body that was always going to be discarded. Measured: an encoding/json decode into map[string]any costs ~11x the wire body in live heap, so the prior ordering let an already-limited caller burn ~110MiB per request before ever seeing the 429. - A new bounded, buffered-channel semaphore (Gateway.bodyAdmission, mirroring limits.go's spawnTokens shape) caps how many unified/media requests may concurrently hold a decoded body in memory. Acquired non-blockingly at each route's entry via acquireBodyAdmission; on exhaustion, returns 503 with Retry-After rather than blocking. Sized by defaultBodyAdmissionCap (GOMAXPROCS * 8, clamped to [16, 128]); Config.MaxInFlightBodyRequests, when set, always overrides the self-tuned default. - The unified chat/embeddings routes now decode against a new, narrower maxUnifiedRequestBytes (4MiB) instead of the shared maxRequestBytes (10MiB), which the media JSON routes, audio/transcriptions' multipart cap, and the MCP federation JSON-RPC routes keep unchanged. 1MiB (the audit's own first suggestion) was evaluated and rejected: this package documents and exercises inline base64 image_url content parts in chat messages (translate_anthropic.go, translate_gemini.go), and a single moderately-sized photo commonly exceeds 1MiB once base64-encoded. 4MiB preserves that documented single-inline-image case while still cutting worst-case per-request decode amplification from ~110MiB to ~44MiB. routes_media.go's resolveMediaRequest is split: model resolution stays (renamed resolveMediaModel), the limit check moves into the shared admitRequest. Tests: admission-before-decode is asserted directly (a 429'd request's body is never Read), semaphore exhaustion is asserted deterministically by filling the channel directly, the self-tune/override contract and its clamp bounds are pinned, and a zero-config run of ordinary traffic is asserted to see no new 503s. A dedicated test proves the chosen 4MiB cap still accepts a realistic ~1.5MiB inline image — the case 1MiB would have broken. |
||
|
|
bf33732123 |
perf(registry): cache the encoded /v1/models response per group
GET /v1/models cost 15.9ms, 21.3MB, 552,884 allocs per call at the
live 1,033-model catalog (registry.go's listFor plus routes_unified.go's
json.Encoder), uncached — and OpenAI SDK clients poll this endpoint per
session, so an unchanged catalog was being re-walked and re-marshaled
on every request.
Add modelRegistry.modelsJSON(grp), caching the fully encoded
{object:list,data:[...]} body per (*group, modelsGen). modelsGen
is an atomic counter bumped by a new modelRegistry.finishRefresh
wrapper around providerState.finishRefresh — the only two places that
ever mutate a provider's discovered set or discovery-captured metadata
(warmFill, refreshProvider) already funnel through providerState.
finishRefresh once per refresh cycle, so wrapping it is sufficient to
invalidate the cache on every possible change, including a
metadata-only update on an otherwise-failed refresh (captureModelMetadata
runs unconditionally alongside finishRefresh in both call sites).
Bumped unconditionally rather than only on success, since a failed
listModels fetch can still pair with a successful metadata capture.
Cached per *group pointer: authStore builds every group once, at
construction, and never rebuilds or replaces that map afterward — a
config reload constructs an entirely new Gateway (and modelRegistry)
with its own empty cache, so a *group key can never alias a different
group's catalog either within one registry's lifetime or across a
reload.
routes_unified.go's handleModels call site shrinks to a single write of
modelsJSON's result, replacing the previous inline listFor+json.Encode;
routes_unified.go is otherwise untouched (owned by a sibling change in
this same effort).
|
||
|
|
90eb58171e |
perf(redis_store): send EXPIRE only once per key per process lifetime
31 of 74 commands per request in the interpreted-harness audit were EXPIRE, emitted unconditionally after every INCRBY. Deleting EXPIRE entirely (upper bound) measured 74->43 cmds/req, p50 867.9->697.9us (-19.6%), conc=16 throughput 1,477->2,336 req/s (+58%), allocs -26%. Route chosen: a bounded first-touch-per-key cache (expireOnce), not EVALSHA+Lua. windowKey (limits.go) already embeds each counter's own time bucket in its key string, and every TTL callers pass is deliberately far longer than its window's own natural length purely as a stale-key GC margin (limits.go's own doc comment on dayWindowTTL/monthWindowTTL/etc.), not a sliding-window mechanism — so once EXPIRE has been sent for an exact key, every further INCRBY against that SAME key happens well within its own bucket's window, comfortably inside the TTL's margin. Skipping the redundant resend cannot leave a key un-expiring: a bucket's key simply stops being written once its window rolls over (a new bucket string takes its place). expireOnce is bounded (expireOnceCacheMax=4096, FIFO eviction) so a long-lived process's ever-rolling buckets cannot grow it unboundedly; an evicted key just gets one redundant-but-harmless EXPIRE again, exactly as if never touched before. incrBy/incrMulti/incrAndGetMulti now build a variable-stride command list (each entry contributes its own INCRBY, plus EXPIRE only when expireOnce(key) is true) with an explicit incrReplyIdx/base tracking each entry's actual reply position — no longer a fixed i*2 stride, since a skipped EXPIRE shifts every later command left by one. EVALSHA+Lua was rejected as the shippable route here: it would rewrite every command-building and reply-index path, requires NOSCRIPT fallback handling, and every existing wire-level test (dozens of INCRBY+EXPIRE-scripted fakeRESPServer cases) would need rebuilding around SHA1 digests for no additional correctness benefit over the simpler first-touch cache. |
||
|
|
2309a59b40 |
perf(resp): pool redis connections instead of one mutex-guarded conn
respClient held a single net.Conn behind one mutex, serializing every caller's whole round trip. Interpreted-harness audit measured this as the throughput ceiling: 1,458 req/s Redis-backed vs 7,811 req/s on memoryStore for the same workload, latency scaling near-linearly with concurrency (869us@conc=1 -> 10.81ms@conc=16). Replace the single connection with a pool of independently usable connections (respConn slots in a buffered channel), each acquired for one caller's whole round trip and returned via a deferred send back onto the pool. A broken connection is discarded (closeConn) but its slot always returns to the pool, so pool size never shrinks after an error. Default size is GOMAXPROCS clamped to [4, 8] (defaultRespPoolSize); RedisConfig.PoolSize lets an operator override it explicitly, per the house rule that an explicit override always wins over self-tuning. Preserves exactly: the 2s respCallTimeout call deadline, reconnect- once-on-error semantics, nested-array rejection, the RESP size bounds, and every existing pipelining contract (incrMulti, incrAndGetMulti, getMulti, cache get/set) — nothing outside resp.go references a connection field directly. Interpreted-harness prototype (variants/resp_pool8.go, pool=8) measured at a realistic 200us Redis RTT, conc=16: p50 24.06ms -> 3.24ms (-86%), throughput 663 -> 4,670 req/s (+599%, +-1% over 3 reps); a pool=1 control matched HEAD exactly, confirming the win is parallelism, not refactor noise. cache_test.go and llmgateway_test.go: adjust white-box tests that issue multiple sequential calls against one respClient scripted with runFakeRESPServer's single-connection model — force pool size 1 there (newRESPClientPool) so the test still pins one connection's exact wire sequence; production callers are unaffected. |
||
|
|
df39ad3739 |
chore(admin): regenerate baked admin assets
Regenerated via `make admin-ui` against the new webui/generate.mjs chunking. Decoded asset bytes are byte-identical to the prior baked file (SHA-256 over adminIndexHTML + every adminAssets[name].body, sorted by name: 7bf96c68a64d26586057c714ba8b6ee393ad361a47ab1acb21d1d260955d3edd before and after — verified with a throwaway test against both generator outputs on the same webui/dist/ build). contentType strings and asset map keys are unchanged. Measured under Yaegi (github.com/traefik/yaegi, the interpreter this plugin actually runs under) with a standalone import-timing harness pointed at this tree: package import 233.8ms -> 39.8ms (median of 3 runs each), heap after import 32.0MB -> 22.8MB. |
||
|
|
e5c3cd4b97 |
perf(admin): stop wrapping baked assets in a 7500-literal + chain
webui/generate.mjs split each admin-panel asset's base64 into 120-char Go string literals joined with `+` (~7,550 literals, ~7,538 `+` ops across index.html + the JS/CSS bundle). A compiled Go binary folds that at compile time for free, but this plugin loads under Yaegi (github.com/traefik/yaegi), which folds a chain of N literal `+` operations at import time by repeatedly reallocating and copying the whole already-folded prefix: O(N^2). Measured: ~186ms of a ~223-240ms package import was this folding alone, versus ~3-4ms to decode the asset bytes. Replace the 120-char LINE_WIDTH with a 1,000,000-char CHUNK_CHARS cap. Every asset this panel ships today (largest ~516,000 base64 chars) fits in a single literal, so goBase64Expr now emits one decodeAdminAsset call with zero `+` folds per asset. The cap still bounds a future, much larger bundle to a small number of large chunks rather than one unbounded line; single-line Go string literals in this size class were verified against gofmt/go vet/go build before picking the constant. No behavior change: decoded asset bytes, contentType strings, and the asset map keys are unaffected by how the base64 is chunked into source literals — see the following regen commit for the byte-identity proof. |
||
|
|
e06d8bf5cd |
fix(passthrough): fail closed on a duplicate top-level model key
RFC 8259 permits duplicate names and every mainstream upstream parser
resolves them last-wins, while a passthrough body is forwarded verbatim.
Reporting the first occurrence let a model-restricted caller authorize
{"model":"allowed","model":"expensive"} against the allowed one and
execute the expensive one. Refuse to answer instead, matching
extractMultipartModel's existing duplicate rule.
Found by the security audit; a padded-duplicate variant that hides the
second key beyond the 64KiB peek window is tracked separately.
|
||
|
|
e57a97d63e |
fix(passthrough): close Content-Type bypass and matcher-parity bug
Independent review measured two real gaps in the round-1 model
enforcement and fixed both here, plus documentation and comment
regressions the round introduced.
MUST FIX: peekPassthroughModel's Content-Type gate was an ALLOW-list
("application/json" or skip") and fully client-controlled. The
reviewer sent "Content-Type: text/plain" with {"model":"EXPENSIVE"}
and got a 200 from a group scoped to a cheap model. This commit
replaces the whole gating approach:
1. A group with an empty Models list has nothing for allowsModel to
reject, so the request body is never read at all
(group.hasModelRestriction). This is the overwhelming majority of
traffic, including the operator's own "home" group, and removes
the memory-buffering concern entirely for it.
2. For a group WITH a restricted Models list, the Content-Type gate
is now a SKIP-list of genuinely binary types (multipart/, audio/,
image/, video/, application/octet-stream). Every other
Content-Type, including "text/plain" or none at all, is
inspected. The attacker no longer controls whether the check
runs.
3. The peek reads at most 64KiB (maxModelPeekBytes), restored via
io.MultiReader so the remainder streams straight to the upstream
instead of a second full buffer. A bounded JSON token walk
(scanTopLevelModel) finds a top-level "model" key even when the
rest of the body is truncated past the cap.
4. A restricted group whose peek window never finds "model" is
denied (403), fail closed. This also means a Gemini passthrough
request (its model lives in the URL, never the body) is always
denied for a model-restricted group; documented in
maxModelPeekBytes' own comment, the handler's doc comment, and
the README.
Matcher parity: the round-1 version glued providerName+"/" onto
whatever the body sent unconditionally, so an already-prefixed body
("openai/x") produced "openai/openai/x" and false-403'd a legitimate
client. allowsPassthroughModel now reuses modelRegistry.
splitConfiguredProvider — the same rule resolve/resolveAgainst apply
to a client-given id — to strip a leading segment only when it names
an actually configured provider, before generating candidates.
Also in this commit:
- Restored ~26 lines of doc comments fieldalignment -fix silently
dropped when it reordered the group struct (auth.go: cache,
cacheTTL) and ProviderConfig (llmgateway.go: MetadataPath, and the
Passthrough field's own new comment). Verified nothing else was
reordered by re-running fieldalignment: 0 issues.
- Documented the path.Match footgun a non-empty PassthroughPaths
inherits: "*" does not cross "/", so ["*"] denies almost all real
multi-segment passthrough traffic rather than allowing it. Empty is
the actual allow-all value. Documented on the config field, the
allowsPassthroughPath method, and the handler's own doc comment.
- Renamed the local "model" that shadowed the outer request-model
variable near extractPassthroughUsage's call site to "respModel" —
the new gating already scopes the outer one to a narrower block, so
this was no longer a live shadow, but the rename removes any future
ambiguity between the request's model and the response's.
- README: rewrote the two "Unified vs. passthrough" table cells that
described the OPPOSITE of this feature's actual behavior, added a
"Native passthrough model authorization" section, and added
providers.<name>.passthrough / groups.<name>.passthroughPaths rows
to the config tables.
New tests: the Content-Type-bypass regression (text/plain, absent,
form-urlencoded, all now 403), the matcher-parity case (already-
prefixed body allowed), fail-closed on a binary body for a restricted
group, skip-entirely for an unrestricted group, byte-identical body
forwarding for a body larger than the peek cap and for a
ContentLength<0 (chunked) request, and a scanTopLevelModel unit table
covering truncation before/after the model field, nested values, and
malformed input.
Gate re-run in full after these changes: gofmt, go vet, fieldalignment,
golangci-lint (0 issues), go test -race (ok), make yaegi-check (OK),
make integration (ok, real Traefik + Redis). Re-ran the interpreted
Yaegi harness: RTT/req unchanged at 3.00, cmds/req unchanged at 74.00.
|
||
|
|
311dd20a8e |
fix(limits): correct dangling comments and redisStore edge cases
Independent review round approved the fusion itself (11 to 3 RTT/req, precedence verified branch by branch, latch/fail-open checked under Yaegi against a dead Redis). This commit addresses the follow-up items raised against that same change. Six doc comments in limits.go still named evaluateScope, budgetViolation, or tokenBudgetViolation as if they were live functions to consult. All three were removed by the fusion commit; the comments now point at checkAndCount and buildBudgetProbes, where the same logic lives today. One further comment claimed checkAndCount still slices a "storeIncrMulti result" — it now slices storeIncrAndGetMulti's own incrVals. TestCheckAndCount_FusedViolationPrecedence's doc comment claimed every subtest engineers two simultaneous violation conditions. The seventh engineers only one (the group violates; the user carries no limits at all) — it pins a different claim, that a later scope is still evaluated when an earlier one has nothing to check. The comment now says so. redisStore.incrAndGetMulti gained the same len()==0 guard incrMulti already has, and a table-driven test proves a RESP error or a non-integer value on a read key fails the whole call, matching respClient.getBatch's own equivalent table. Re-ran the interpreted Yaegi harness after these changes: RTT/req stays at 3.00, cmds/req stays at 74.00 — the fusion's throughput win is unaffected by this round's fixes. |
||
|
|
e3ef4084d2 |
feat(passthrough): add model, path, and header authorization hardening
handlePassthrough checked only the provider allowlist, never the model or the path. A tenant scoped to one cheap model could reach a provider's entire native API (files, batches, fine-tuning) on the operator's key, and client identity headers reached keyless in-cluster MCP/A2A targets unfiltered. This change closes both gaps, additively: every new field defaults to reproducing prior behavior exactly. Model enforcement: when a passthrough JSON body carries a top-level "model" field, handlePassthrough checks it against the group's model glob, in both the bare and provider-prefixed form (the same dual-candidate matcher the unified route's resolveAgainst already applies). A body with no inspectable model (Gemini's URL-embedded model, a multipart upload, a non-JSON body) falls back to provider-only authorization, unchanged from before this round. Per-provider toggle: ProviderConfig.Passthrough, nil or true by default, lets an operator disable a provider's native passthrough route. A disabled provider now reports the ordinary unknown-route 404, checked in ServeHTTP's own route gate, before auth even runs. Per-group path allowlist: GroupConfig.PassthroughPaths, empty by default (matching every other allow-list field's empty-means-all semantics), restricts which native rest path a group may address. Dangerous-header stripping: proxyUpstream now additionally strips Forwarded, X-Forwarded-*, X-Auth-*, X-Remote-User, X-Remote-Groups, and Cookie on both the native-passthrough and MCP/A2A target-proxy paths, plus OpenAI-Organization, OpenAI-Project, and anthropic-beta on native passthrough only. This is an additive denylist expansion, not an allowlist inversion; ordinary headers still pass through. A dedicated test asserts a config with none of the new fields set behaves identically to before this change. The operator's home group (providers/models/mcpServers/agents/passthroughPaths all empty) stays fully unrestricted, and macstudio-rerank/parakeet-mlx/piper-tts stay reachable through native passthrough by default. |
||
|
|
a418814b34 |
perf(limits): fuse admission reads into one round trip
checkAndCount already batched its request-counter increments into one pipeline. evaluateScope then read every scope's token and cost budgets through up to four further serial round trips per scope. A request with a fully-limited user and group scope paid nine round trips before the upstream call started. This change fuses every budget read into the SAME pipeline as the increments, through a new incrAndGetMulti method on counterStore. The reads are safe to fuse because they touch disjoint keys (req:* versus tokin:*/tokout:*/cost:*), and tokens and cost are only ever written by account() after the response completes, never by checkAndCount itself. Violation precedence stays identical to the pre-fusion implementation: a user's own violation still wins over their group's, and within one scope the order stays requests-per-minute, requests-per-day, tokens-per-day, tokens-per-month, cost-per-day, cost-per-month. New table-driven tests pin this precedence directly, engineering two simultaneous violation conditions per case and asserting only the higher-precedence one is reported. evaluateScope, budgetViolation, and tokenBudgetViolation are removed; buildBudgetProbes plus a loop inline the same logic into checkAndCount, which is what lets their reads share the fused round trip. Measured on the interpreted Yaegi harness (yaegi v0.16.1, the runtime this plugin actually ships under): RTT/req for a fully-limited chat request drops from 10.99 to 3.00, with cmds/req unchanged at 74 (this is a round-trip fix, not a command-count or allocation change). Single-request throughput rises from 112 to 335 req/s; at concurrency 16, from 121 to 380 req/s. counterStore.incrAndGetMulti is a required interface method, not an optional capability behind a type assertion: Yaegi can silently return ok=false when a comma-ok assertion checks a compiled concrete value against a plugin-declared interface (Trap 4, documented in matchesSentinel's own doc comment). A plain interface method call carries no such risk and is the same mechanism incrMulti already uses in production. |
||
|
|
2d10509e20 |
docs: render correctly on the Traefik plugin catalog
The catalog uses a plain CommonMark renderer, not GitHub's: the "[!WARNING]" alert syntax printed literally and the mermaid block rendered as a wall of diagram source. Use a portable bold blockquote and fold the diagram into <details> so plain renderers collapse it while GitHub still draws it. |
||
|
|
1875ee7b8b |
fix(registry): defer context cancels on the refresh panic path
A panic in listModels skipped the inline cancel, holding the timeout timer until it fired. Review follow-up from the v0.2.3 final verdict. |
||
|
|
0a37fa1b47 |
chore(admin): regenerate baked admin assets
make admin-ui: vue-tsc -b && vite build, then webui/generate.mjs against webui/dist/, gofmt -w'd. Reflects the review-round webui fixes: the compact-format precision corrections, the ARIA-valid hover-text components, and the ProvidersView.vue polish. |
||
|
|
171f6d04c9 |
docs(readme): document model metadata (review SHOULD-7)
New 'Model metadata' section: the context/cost layering order, the modelMeta config override example (mirrors the operator's own cluster snippet), alias inheritance, the ":free" naming convention, the metadataPath discovery capture (including the loaded-vs-max-context volatility note — a discovery refresh can legitimately report a different context window across reloads), the pricing-sync/built-in table workflow, and the two GET /v1/models extension fields with a worked example. Config reference tables gain a ModelMetaConfig subsection and modelMeta/metadataPath rows on Config/ProviderConfig; the Admin section notes the per-provider and per-alias modelMeta fields on GET /admin/api/overview. |
||
|
|
fb1b2a36ff |
test(yaegi-check): exercise the builtin table lookup (review SHOULD-6)
The harness previously only ever proved resolveModelMeta's CONFIG- OVERRIDE layer runs interpreted (testDataWantModel carries an explicit modelMeta entry). It never exercised an interpreted LOOKUP into the real, 236-entry builtinModelMetaTable map literal at all. Adds "gpt-4o" to the harness's openai provider with NO modelMeta override of its own, so its GET /v1/models context_window can only come from the builtin-table branch. Its expected value is read directly out of pricing_data_gen.go at harness run time (readBuiltinContextTokens), not hardcoded, so this assertion self-updates across a `make pricing-sync` regeneration instead of pinning a number that could legitimately drift when upstream LiteLLM data changes. Also switched both existing GET /v1/models assertions from raw strings.Contains substring matching to a structured JSON decode keyed by model id — a bare substring check could not tell which model entry a given context_window value actually belonged to, which now matters with two entries in play. |
||
|
|
9b60c0afae |
fix(webui): ARIA-valid hover text, alias metadata guard, context-chip polish
CompactNumber.vue and ProviderRateBadge.vue both put aria-label on a
plain <span> — axe's aria-prohibited-attr rule: a <span>'s implicit
ARIA role ("generic") does not support accessible-name computation at
all, so the attribute was simply invalid there, not just discouraged.
Both now use the visible-text-hidden-plus-sr-only-full-text pattern
instead: the compact/label text is marked aria-hidden="true", and a
sr-only sibling span carries the exact value/detail for assistive tech.
title stays on the outer element for mouse hover, unchanged. Folded
review minor.
ProvidersView.vue: the alias table's ModelChip cell read
row.original.modelMeta.contextTokens without a guard, unlike the
provider path's own modelMetaFor fallback — a response from a server
build older than this field (version skew) would throw instead of just
omitting the hover detail. Now optional-chained to match. The visible
context chip beside each model gains title="context window" and
text-xs (was an arbitrary text-[10px]), matching the rest of the panel's
muted-label sizing convention. Folded review minors.
|
||
|
|
5e3cf919d9 |
fix(webui): compact-format precision bugs (review IMPORTANT-2/3)
formatCompactCount: Math.floor(scaled*factor)/factor under-reported by one unit whenever the intermediate scaled=abs/divisor value was not exactly representable in binary floating point (8_700_000 -> "8.69M", not "8.7M" — 8.7 itself has no exact double representation, so the original two-step division/floor landed on 869 instead of 870). Reordered to floor(abs*factor/divisor) — multiplying the original INTEGER first keeps every intermediate value exact for any realistic count, since abs*factor stays within Number.MAX_SAFE_INTEGER. New rows: 8_700_000 -> "8.7M", 1_130_000 -> "1.13M". (review IMPORTANT-2) formatContextWindow: always divided by 1024 (binary-K), which is right for a genuinely binary context size (262144 -> "256k") but wrong for a decimal, marketing-style round count (128000 -> "125k", an odd number nobody would recognize as "128K"). A plain "tokens % 1024 == 0" check cannot separate these — 128000 is ALSO an exact multiple of 1024 (125 x 1024) despite not being a power of two. The actual, consistent discriminator is POWER OF TWO: 262144 = 2^18, 128000 = 2^10 x 5^3 (not a power of two). Binary-K/M now applies only when tokens is an exact power of two; decimal-K/M otherwise. Fixed rows: 128000 -> "128k" (was "125k"), 1_000_000 -> "1M" (was "977k"), 2_000_000 -> "2M" (was "1953k"); 262144 -> "256k" and 32768 -> "32k" unchanged. (review IMPORTANT-3) |
||
|
|
e3996d88bf |
fix(pricing): content-digest snapshot instead of a wall-clock date
The generated pricing_data_gen.go header used to stamp today's date on every `make pricing-sync` run, so a re-run against genuinely unchanged upstream data still touched the file and produced a spurious daily diff with nothing real to review. The header now records a short SHA-256 digest of the FETCHED bytes instead — identical upstream data always reproduces a byte-identical generated file. Folded review minor. |
||
|
|
962f5eeeed |
fix(providers): validate metadataPath has a leading slash
fetchModelMetadata (provider_openai.go) builds its request URL by plain string concatenation, a.baseURL+a.metadataPath. A metadataPath missing its leading slash would silently glue onto baseURL's own path with no separator (e.g. "http://host:1234api/v0/models"), a malformed URL that only ever surfaced as a confusing runtime fetch failure. buildAdapters now rejects a non-empty metadataPath without a leading "/" at construction, naming the provider, the same fail-fast treatment every other malformed config value already gets. Empty (the default) is always valid. Folded review minor. |
||
|
|
82dcaa5309 |
fix(registry): metadata capture gets its own timeout + panic isolation
warmFill/refreshProvider used to reuse listModels' own context for the subsequent captureModelMetadata call — a listModels attempt that took real, non-trivial time (or timed out outright) guaranteed the metadata fetch an already-spent, near-expired context, producing a spurious deadline error and log line every warm fill or refresh for an otherwise healthy provider. Both call sites now give captureModelMetadata its own fresh context.WithTimeout budget, computed after listModels returns. (review SHOULD-4) captureModelMetadata also gains its own deferred recover, isolating a panic inside fetchModelMetadata (or a future modelMetadataFetcher implementation) from refreshProvider's OUTER recover — which would otherwise overwrite the (already-nil, already-correct) err variable from a successful listModels call and cause finishRefresh to discard a freshly fetched, genuinely good model list over an unrelated bug in the metadata path. "Metadata must never fail discovery" is now enforced on the panic path, not just the plain-error-return one already handled. (review SHOULD-5) New regression tests: a fresh-timeout-budget check (a slow simulated listModels must not shrink the metadata fetch's own deadline) and a panic-in-fetchModelMetadata check (discovery must still succeed and record both models). |
||
|
|
933d869e32 |
fix(modelmeta): free-tier suffix layer (review IMPORTANT-1)
A ":free"-suffixed model id (a real provider naming convention, e.g. OpenRouter's own live API) is free by naming convention alone, independent of whatever the built-in table does or does not track for that exact id. resolveModelMeta's cost switch gains a new case, ranked above the built-in table and below an operator's own config override — so a provider's free-tier naming is never shadowed by a stale or coincidental paid built-in price under the identical key, and an operator can still override it explicitly if they choose. Context is untouched by this rule: it keeps resolving through the normal config/ discovery/builtin/absent layers, never inherited from an unrelated entry. Also makes resolveModelMeta's own Free case assign 0, 0, true explicitly (matching resolveAliasModelMeta's existing style) instead of relying on the zero-value default — defensive consistency, folded review minor. Independently reviewed and reproduced against the same live LiteLLM fetch: zero ":free"-suffixed keys exist anywhere in the 3111-entry upstream file, confirming the earlier grounding note in the v0.23 report. This layer is a general resolution rule for a naming convention provider APIs use in live discovery, not something the pruned build-time table needs to carry any entries for. |
||
|
|
002ee3b143 |
chore(admin): regenerate baked admin assets
make admin-ui: vue-tsc -b && vite build, then webui/generate.mjs against webui/dist/, gofmt -w'd. Reflects every webui/ change in this branch: feature v0.23's metadata hover/chips and the compact-count formatting addition. |
||
|
|
040ff40ae0 |
feat(webui): compact usage-count formatting
Operator scope addition: raw token/request counts reaching millions/
billions were unreadable as bare digits. formatCompactCount (lib/
format.ts, shipped in the previous commit alongside this feature's own
helpers) renders an SI-style label: below 10,000 the exact integer with
thousands separators, at or above it three significant figures with a
k/M/B suffix ("12.3k", "456k", "1.23M", "45.6M", "1.23B") —
every digit past the third significant one is FLOORED, never rounded,
the same honesty rule formatRatePercent already applies to the
success-rate badge: a count of 999,950 must read "999k", never
"1,000k", since it has not actually crossed that boundary.
CompactNumber.vue is the one shared, reusable presentational component
(component-first: no per-call-site wrapper markup) — renders the
compact form as text, with the exact value on both title and aria-label,
mirroring ProviderRateBadge.vue/ModelChip.vue's own hover-parity
pattern.
Applied everywhere a count renders: usage-columns.ts's numericColumn
(reqMin/reqDay/tokIn-out per day and month — cost columns explicitly
opt out via a new compact:false argument, since formatCost already
shows full precision), target-columns.ts's MCP/agent request counters,
UsageView.vue's Total card and group-accordion trigger/detail stat
grid, and UsageChart.vue's Chart.js y-axis ticks (compact only — no
room for the raw figure on an axis label) and tooltip labels (compact
form WITH the precise raw value alongside, e.g. "Tokens in: 1.23M
(1,234,567)").
Sorting is unaffected by construction: every numericColumn's accessorFn
still returns the raw number (what getSortedRowModel actually sorts by)
regardless of what the cell renders — usage-columns.spec.ts pins this
directly, including a sort-order assertion that would fail under a
lexicographic (string) sort but passes under the numeric one.
|
||
|
|
eac0862d68 |
feat(admin,webui): metadata exposure
admin.go: adminProviderView gains ModelMeta (map[string]adminModelMetaView,
keyed by upstream model id) and adminAliasView gains its own ModelMeta —
both built via g.registry.resolveMetaFor/resolveMetaForAliasName
(feature v0.23). adminModelMetaView uses pointer fields
(*int/*float64), not plain zero values: a nil pointer means unknown, a
non-nil pointer (even one pointing at 0) means known, so a free model's
real zero cost never gets confused with an absent one over JSON.
resolveMetaForAliasName resolves an alias's metadata for this
unrestricted admin view via an internal &group{} (GroupConfig's own
documented empty-means-all semantics) — buildAdminOverview already
shows every provider/alias unconditionally, with no group filtering
anywhere else in its response either.
webui/src/types/api.ts: AdminModelMetaView mirrors the Go shape
(optional numbers, undefined means unknown); AdminProviderView.modelMeta
and AdminAliasView.modelMeta wire it through.
webui/src/lib/format.ts: formatContextWindow (binary-K "256k"-style),
formatModelCostHover ("in $X / out $Y per MTok", or "free"), and
their shared formatUsdPerMTok building block.
ModelChip.vue (operator scope refinement): every chip — the accordion's
model chips, the alias table's chips, a group's usage-detail chips —
gains optional contextTokens/inputPerMTokUsd/outputPerMTokUsd props.
When known, they append a resolved-metadata phrase to the existing
copy-state hover text, bound to BOTH title and aria-label (the same
mouse-vs-keyboard/screen-reader parity ProviderRateBadge.vue already
applies); when nothing is known, the hover is unchanged from before this
feature. An alias's chip carries its INHERITED metadata, matching the
alias-inheritance rule the backend already applies.
ProvidersView.vue: the accordion's model list gains a visible, muted
context-only chip ("256k") beside each ModelChip — cost stays
hover-only on the chip itself, per the operator's own "chips +
hover would be redundant on price" resolution.
|
||
|
|
0c61296c73 |
feat(registry): model metadata capture and resolution
Config gets two new surfaces (llmgateway.go): Config.ModelMeta (map[string]*ModelMetaConfig, keyed by exact "provider/model" or a bare model id) for operator overrides, and ProviderConfig.MetadataPath for openai-type providers whose discovery should also capture per-model context (LM Studio's native /api/v0/models shape). modelmeta.go adds the layering itself: resolveModelMeta applies config override > discovered context > builtin table > absent for context, and config override/free > builtin > absent for cost (discovery never reports cost). resolveAliasModelMeta implements alias inheritance: an alias inherits its target's fully resolved metadata unless the alias itself has its own modelMeta entry, applied per field. validateModelMeta rejects negative values and a free:true/non-zero-cost contradiction at construction. registry.go wires this into the request path: providerState gains a discoveredContext map (stale-while-error, mirroring discovered's own convention) with contextFor/setDiscoveredContext; a new optional modelMetadataFetcher interface, type-asserted (interpreted-to-interpreted, documented as yaegi-safe against the Trap-4 restriction) rather than added to providerAdapter, lets warmFill/refreshProvider capture metadata alongside listModels without touching anthropic/gemini adapters at all. modelObject/listFor now attach resolved context_window/pricing to every GET /v1/models entry, including aliases (resolveMetaFor/ resolveMetaForAlias/resolveMetaForAliasName). provider_openai.go implements modelMetadataFetcher: fetchModelMetadata reads ProviderConfig.MetadataPath, preferring loaded_context_length over max_context_length when the LM Studio response reports both. providers.go wires MetadataPath onto the adapter in buildAdapters. tools/yaegi-check/main.go adds one cheap modelMeta config-override assertion to the existing attempt-accounting harness, proving resolveModelMeta's config-override layer and the context_window/pricing extension fields both run correctly under the real Yaegi interpreter. |
||
|
|
e8398e6d49 |
feat(pricing): litellm sync generator + table
Add tools/pricing-sync: a standalone, compiled Go tool that fetches
LiteLLM's model_prices_and_context_window.json at build time and
regenerates pricing_data_gen.go, a plain map[string]builtinModelMeta
literal keyed by bare model id (context window, input/output cost in
micro-USD per million tokens).
Pruning rule (documented on the tool's own package doc comment): keep
only entries whose litellm_provider matches this gateway's own provider
types (openai, anthropic, deepseek, xai, minimax, cerebras, dashscope
for qwen/alibaba, and moonshot for Kimi models) and whose mode is chat
or embedding, dropping everything else — a 3111-entry table is a real
startup cost under Yaegi interpretation, so the generated table stays at
236 entries. Bare-id collisions across LiteLLM keys resolve
deterministically: a bare key beats a provider-prefixed one, then
provider priority, then lexicographic order.
Naming bridges: a small, data-driven table (namingBridges) duplicates an
entry under a second bare id when a provider's own live API disagrees
with LiteLLM's naming for the identical model — seeded with MiniMax's
lightning/highspeed dual naming, verified against a live deployment.
Coverage-audit follow-up (verified against a live fetch of the same
source, 2026-08): moonshot's own "litellm_provider" tag carries 23
real Kimi entries (kimi-k2.5, kimi-k3, ...), added to the allowlist as a
first-class provider rather than a per-id bridge, since the table is
already keyed by bare id regardless of provider. Two claims from a
coverage-audit request could NOT be verified against this same fetched
file and were deliberately NOT implemented: no key anywhere in the
3111-entry source ends in ":free" (an openrouter free-tier naming
convention the request assumed exists in this file), and no
"qwen/"-prefixed key exists at all — see the report for the full
grounding note. Also confirmed already-correct by design, no change
needed: audio/responses-mode OpenAI entries ("codex-mini-latest" and
friends) are excluded by the existing mode allowlist, not by any
provider special-case; image/video media models are excluded the same
way.
builtinModelMeta itself lives in its own file (builtin_model_meta.go),
separate from modelmeta.go's resolution logic, so this commit's table
and generator compile standalone.
make pricing-sync regenerates the table; add its gitignore entry for the
tool's own compiled binary.
|
||
|
|
909dba5a44 |
fix(limits): recover and log in the telemetry spawn, broaden the yaegi trap note
The spawned write gains the registry-precedent recover (an unrecovered panic off any request goroutine crashes the whole Traefik process) and rate-limited drop logging so a stressed store is distinguishable from a healthy provider. The matchesSentinel doc now records the instrumented finding: ANY compiled concrete value fails a comma-ok assertion against ANY interpreter-declared interface, method count irrelevant, silently — plus the compiled-origin trip-wire that keeps errors.Is safe here. |
||
|
|
21f51dc282 |
docs(readme): note bounded fire-and-forget writes for provider counters
Re-read the provider/model success-rate paragraph against the FIXED
deadline behavior (v0.22 review round, round 2) per instruction — the
claim itself ("a real deadline counts as a failure") was already
accurate and needed no correction; the multi-wrap bug it was allegedly
describing never surfaced through the README, since the README
describes intended behavior, not the wrap-depth implementation detail
that broke it. Added: FOLDED-1's bound (64 in flight per process,
lossy-under-pressure by design) on the fire-and-forget write this
paragraph already described, and an explicit note that enforcement never
goes through this path.
|
||
|
|
7eac56cb46 |
docs(webui): fix stale percent in minuteRateTitle doc example
SHOULD-B (v0.22 review round, round 2): the doc comment still showed "(92%)" for 12 attempts/1 failure — stale against the floor-not-round fix (formatRatePercent) landed earlier in this same file, which the spec's own test asserts as 91%. No functional change; comment only. No admin-asset regen needed — verified byte-identical dist/ output before and after (a doc comment does not reach the minified bundle). |
||
|
|
eddcbe35bd |
fix(providers): multi-wrap deadline detection was dead on 3 of 4 routes
BLOCKER (v0.22 review round, round 2): matchesSentinel's hand-rolled
Unwrap walk (round 1's SHOULD-1 fix) stopped dead the moment it hit
providers.go's own multi-%w wrapping. fmt.Errorf with more than one %w
verb builds a *fmt.wrapErrors implementing Unwrap() []error, not
Unwrap() error — a comma-ok assertion against a single-error Unwrap
interface alone never reaches past it. All three of providers.go's own
wrap sites use multi-%w (upstreamJSON's encode-failure path, "%w:
encode request body: %w"; upstreamBytes' build-request path, "%w: %w:
build request: %w"; and upstreamBytes' client.Do path, "%w: %w" — the
one a real request-context deadline reaches through). Result: a hung
upstream on the unified chat/embeddings/media routes recorded as 100%
healthy; only native passthrough (whose error is never re-wrapped by
upstreamBytes) worked.
Round 2's first attempt added a second hand-rolled interface
(multiUnwrapper, Unwrap() []error) and recursed into it — verified
correct against every wrap shape under `go test`, INCLUDING the exact
double-/triple-%w shapes providers.go itself produces. It still did not
work. tools/yaegi-check's own SHOULD-A extension (below) caught it: a
type assertion of a COMPILED concrete value (*fmt.wrapErrors, built by
the real fmt package) against an INTERPRETED interface type
(multiUnwrapper, declared in this plugin's own source) silently returns
ok=false under Yaegi, even though %T correctly reports *fmt.wrapErrors
and that type genuinely implements Unwrap() []error in real Go — the
reverse direction of the already-documented errors.As-on-interpreted-
types trap, not previously known to this codebase.
Fix: matchesSentinel now calls real errors.Is instead of hand-rolling
anything. This is safe here specifically, despite the "avoid errors.Is/
As" guidance elsewhere in this codebase: the panic risk that guidance
targets is errors.As checking whether an INTERPRETED type implements
error, which never arises for a plain sentinel comparison against
compiled-origin values (context.DeadlineExceeded, context.Canceled, and
every err this function ever sees) — the identical safe shape retry.go's
isTransient already relies on for these same two sentinels, proven
correct under Yaegi across every prior round. Empirically verified: this
is what made the extended yaegi-check harness pass; the hand-rolled
version did not, despite passing every compiled `go test` row.
Also in this commit:
- Route-level regression tests (TestHandleChat_
ContextDeadlineExceeded_RecordsProviderFailure, routes_unified_test.go;
TestHandlePassthrough_ContextDeadlineExceeded_RecordsProviderFailure,
routes_passthrough_test.go) — a real httptest upstream sleeping past a
real short request-context deadline, adapted from a reviewer-provided
probe. These are what exposed the blocker in the first place; the
sentinel-level table test alone (hand-built error shapes) is exactly
what let it ship.
- Prod-shape rows (double-/triple-%w, byte-for-byte providers.go's own
format strings) folded into the existing classification table
(renamed TestIsDeadlineExceeded_Classification — its own doc comment
now records that this table alone is NOT proof under the interpreter,
after round 2's own history).
- tools/yaegi-check/main.go (SHOULD-A): the harness's claim to prove the
interpreted deadline path was never actually true — its only upstream
answered instantly, so isDeadlineExceeded was never exercised with a
non-nil error at all. Added a second, deliberately slow provider
("slow", provider-prefixed model id so it cannot collide with the
existing fast one) driven with a short request-context deadline,
asserting failuresDay==1 under the real interpreter. This is the
harness that caught the blocker above.
- FOLDED-1: recordProviderAttempt's fire-and-forget store write
(SHOULD-5, round 1) is now bounded — limiter.spawnTokens, a per-limiter
buffered-channel semaphore (providerAttemptSpawnCap=64) the production
spawn closure acquires non-blockingly; a write that cannot start
immediately is DROPPED, not queued. Bounded and lossy-under-pressure by
design: telemetry only, enforcement never goes through spawn.
Per-limiter, not package-level, so the test suite's many parallel
*limiter instances cannot starve each other.
- FOLDED-2: TestRecordProviderAttempt_ProductionSpawn_
WriteEventuallyLandsAsync leaves limiter.spawn at its real production
default (every other test in this file overrides it to run
synchronously) and polls (waitUntil, deadline-bounded, not a fixed
sleep) for the counter to land — closing the -race async-path gap the
synchronous overrides otherwise leave entirely untested.
|
||
|
|
1661cd5144 |
chore(admin): regenerate baked admin assets
Regenerate admin_assets_gen.go (webui/generate.mjs, gofmt -w) from the webui/ build produced by the preceding review-round webui commit, so admin.go's serveAdminPage/serveAdminAsset ship the MUST-1 contrast fix, day-window per-model badges, the accordion "Last refresh" fix, and the badge aria-labels. |
||
|
|
9d079f093c |
docs(readme): provider success-rate counters and admin fields
SHOULD-3 (v0.22 review round). Documents: - The new llmgw:prov:*/llmgw:provmodel:* counter key families under Limits and accounting: format, minute/day-only windows and their TTLs, and the SHOULD-1 semantics — a timed-out attempt counts as a failure, a client-canceled one does not, both on top of retry.go's own transient classification, reused not forked. - Native passthrough's provider-level-only accounting limitation (no resolved model at attempt time). - The new adminProviderView fields (discoveryEnabled, attemptsDay/ failuresDay/attemptsMinute/failuresMinute, modelRates) under Admin's GET /admin/api/overview description. |
||
|
|
abbf5d0967 |
fix(webui): accessible warn token, day-window model badges, review minors
Review round (v0.22) — MUST-1, SHOULD-2's frontend half, and every folded minor that touched webui/. MUST-1 (contrast): the degraded badge's amber previously reused --color-chart-tokens-out, tuned for a chart mark, not body text — measured ~2.22:1 against --card in light mode, a WCAG 1.4.3 failure on exactly the "something's wrong" tier. A new semantic --status-warn token pair (main.css, with a @theme inline mapping) replaces it: #a35c00 light (~5.06:1), #f0ac1f dark (~8.97:1, already passing, redefined anyway so every semantic-color token's dark value lives in one place). ProviderRateBadge.vue's degraded tier now uses border-status-warn/40 text-status-warn. SHOULD-2 (frontend half — see the backend commit for admin.go/limits.go): AdminModelRateView drops attemptsMinute/failuresMinute (the backend no longer sends them at all). ProviderRateBadge.vue's attempts-minute/ failures-minute props become optional; their absence (not a passed- through 0) triggers a new dayRateTitle fallback (lib/provider-rate.ts) — a real "0 attempts this minute" and "no minute data fetched at all" read identically as 0 otherwise, which would misreport a model badge as having live-minute data it never measured. Folded minors: - formatRatePercent floors instead of rounds — only a true 100% rate (zero failures) ever renders "100%"; 99.6% now shows "99%", not a round-tripped "100%" that would overstate it. - providerSuccessRate NaN-guards both inputs (Number.isFinite). - ProvidersView's modelRateFor uses `p.modelRates?.[model]` — a stale client against an older server build could otherwise throw rather than fall back, matching its own doc comment's stated intent. - ProviderRateBadge's title is now also its aria-label — title alone is mouse-only; a keyboard/screen-reader user previously got none of the minute/day detail. - The accordion's "Last refresh" DETAIL row (not just the trigger's compact text) now shares refreshLabel's three-state logic via a new refreshDetailLabel (lib/format.ts) — it previously called formatTimestamp directly, so a discovery-off provider's expanded row still said "never" beside a trigger that correctly said "discovery off". - providerModelScopeID's Go doc comment (limits.go, backend commit) cross-references configNamePattern for why the "/" join is collision- safe even though a model id may itself contain "/". - useTabHash.ts documents its two intentional URL-normalization quirks: case-sensitive hash matching, and an invalid/absent initial hash normalizing only in memory, not in the address bar, until the next tab change. |
||
|
|
56817b41d1 |
test(yaegi): prove attempt-accounting under the interpreter
SHOULD-4 (v0.22 review round): tools/yaegi-check now drives a real chat completion against a local httptest upstream, then GET /admin/api/overview, asserting the resulting provider reports exactly one attempt and zero failures — end to end under Yaegi, not merely compiled: runUnified's attemptRecorder -> retryPolicy.do's context lookup -> limiter.recordProviderAttempt's isTransient/isDeadlineExceeded classification -> its fire-and-forget spawn -> buildAdminOverview's own batched providerUsage read. This also proves the SHOULD-1 hand-rolled errors.Unwrap walk (matchesSentinel/isDeadlineExceeded, limits.go) runs correctly interpreted, which `go test` cannot: that class of bug is exactly what this harness exists to catch. Adapted from a reviewer-provided reference harness (a second json.Unmarshal layering a mock-upstream provider and an admin-flagged user on top of the plugin's own .traefik.yml testData) rather than reinvented — reworked to poll GET /admin/api/overview briefly (SHOULD-5's fire-and-forget write can lag a single immediate read) and to assert on the decoded attemptsDay/failuresDay values instead of only printing them, so a regression here actually fails the gate. |
||
|
|
1b4516efbb |
fix(providers): deadline failures, cheaper polling, async writes
Review round (v0.22): 1 must-fix + 5 should-fix. This covers the three backend items. MUST-1's WCAG fix lives in the webui commit; this is the three provider/ admin-side SHOULD items. SHOULD-1 (timeout blindness): a hung upstream previously read as 100% healthy — isTransient (retry.go) deliberately excludes context.DeadlineExceeded from what it retries (retrying past the gateway's own deadline is pointless), and recordProviderAttempt reused that same classification unforked, so a timed-out attempt recorded no failure. Fixed narrowly in recordProviderAttempt only, per the ruling: isTransient itself stays untouched and shared. A new isDeadlineExceeded adds exactly one extra condition — a context.Canceled error (the client walked away) still counts as an attempt only, from both functions alike. The sentinel match (matchesSentinel) is a hand-rolled errors.Unwrap walk, not errors.Is: providerHTTPError's own doc comment already documents that errors.As panics under Yaegi checking whether an interpreted pointer type implements error; errors.Is walks the identical internal machinery and carries the same risk, so this sidesteps it with a bare `err.(sentinelUnwrapper)` comma-ok assertion instead — plain interface satisfaction, not reflection over a possibly-interpreted type. SHOULD-2 (poll fan-out): a (provider, model) scope's minute-window counters fed nothing but a badge's title text on the Providers tab, so a dashboard with N configured models paid N wasted minute-window reads on every 5s poll. providerCounterKeys/providerUsage now read a kindProviderModel scope's day window only (2 keys, down from 4); a kindProvider scope is unaffected (still 4 — one scope per provider, not per model, so the read amplification never applied there). adminModelRateView drops AttemptsMinute/FailuresMinute entirely, not just unpopulated fields — the webui side (separate commit) falls back to a day-window-only title for a per-model badge. SHOULD-5 (TTFB): recordProviderAttempt's store write is telemetry-only — nothing in the request path gates on it — so it now runs via a new limiter.spawn dependency-injection field (mirrors nowFn/waitFn's existing pattern), defaulting to a real goroutine in production and overridden to run synchronously in every test that asserts on the resulting counter immediately afterward. This removes the synchronous Redis round trip this counter family added to the streaming TTFB path entirely. Loss on process shutdown is accepted (telemetry only). |
||
|
|
080daebb53 |
chore(admin): regenerate baked admin assets
Regenerate admin_assets_gen.go (webui/generate.mjs, gofmt -w) from the webui/ build produced by the preceding feat(webui) commit, so admin.go's serveAdminPage/serveAdminAsset ship the Providers-tab rate badges, refresh labels, and anchor-link syncing. |
||
|
|
2298249df5 |
feat(webui): rate badges, refresh labels, anchor links
Providers tab (Feature A): a new ProviderRateBadge component renders each provider's day-window success rate (attempts-failures)/attempts — always shown when attempts>0 (100% muted, <99% amber via the chart palette's --color-chart-tokens-out token, <90% destructive, reusing the Badge component's existing secondary/outline/destructive idioms rather than inventing a new variant), a distinct "no traffic" badge when attempts=0, and the live minute-window count in its title attribute. The same component renders beside a ModelChip inside a provider's accordion, but only when that model is degraded (<99% with attempts>0) so a long, healthy model list stays uncluttered. The math and its threshold constants live in lib/provider-rate.ts, unit-tested directly (vitest's node environment needs no component mount). Feature B: a provider's "refreshed ..." label (lib/format.ts's new refreshLabel) now reads "discovery off" when DiscoveryEnabled is false, "pending" when it is true but lastRefresh is still unset, and otherwise the unchanged relative-time text — replacing the misleading "refreshed never" a discovery-disabled provider showed before. Feature C: composables/useTabHash syncs App.vue's active tab to the URL hash (#providers/#usage/#charts/#targets, matching the existing TabsTrigger values) with no router dependency. On load it reads the current hash (falling back to "providers" for an absent or invalid one); on tab change it writes the hash back via history.replaceState (no scroll jump, no new history entry per click); a hashchange listener handles back/forward and hand-edited URLs. hashToTab/tabToHash are exported as pure functions so the mapping is unit-tested directly, independent of window/history/location — every DOM touch inside useTabHash itself is guarded behind one hasWindow check so the module still imports cleanly under vitest's node test environment. AuthGate never reads or writes the hash, so a tab named in the URL survives the unauthenticated -> authenticated transition unchanged. |
||
|
|
bf23b41568 |
feat(admin): rates + discovery flag in API
Extend GET /admin/api/overview's existing providers payload rather than adding a sibling endpoint: the route already returns one providers array per request on the dashboard's 5s poll, is already admin-gated and stats-excluded, and the new fields are one more batched limiter read sharing that same request — a second endpoint would duplicate all three properties for no benefit. adminProviderView gains AttemptsDay/FailuresDay/AttemptsMinute/ FailuresMinute (the provider's own current-window counters) and ModelRates (a per-model breakdown, keyed by upstream model id, via the new adminModelRateView) — Feature A. buildAdminOverview builds every provider's and every (provider, model) pair's limitScope in two passes and reads them all through limiter.providerUsage in ONE storeGetMulti call, then slices the flat result back by index. adminProviderView also gains DiscoveryEnabled, mirroring a new providerSnapshot.discoveryEnabled field (registry.go) read straight off providerState — it is set once at construction and never mutated afterward, so it needs neither providerState's mutex nor a place in its existing snapshot() method's mutable-field return shape. Feature B: this lets the webui tell "discovery is off" apart from "discovery is on but has not refreshed yet", which previously both showed the same zero lastRefresh as an identical "refreshed never". |
||
|
|
aafd2a0433 |
feat(providers): success-rate accounting
Track per-provider and per-(provider,upstream-model) upstream-attempt outcomes using the existing fixed-window counter machinery (limits.go), following mcp_a2a.go's per-target counter pattern: kind-embedded keys (kindProvider="prov", kindProviderModel="provmodel") keep a provider scope collision-safe against a user/group/MCP-target/agent of the same name. limiter.recordProviderAttempt(provider, model, resp, err) increments an "attempts" counter on every call, and a "failures" counter only for a provider-fault outcome — reusing retry.go's own isTransient(resp, err) classification exactly (transport/connection error, HTTP 429, or a 5xx), not forking it. Both counters are written at minute and day granularity only; provider health is a now-and-today question, not a billing one, so there is no month window. limiter.providerUsage batches every scope's read into one storeGetMulti round trip, mirroring targetUsage's own single-batch discipline. Wired at every chokepoint an upstream HTTP attempt resolves: - providers.go's upstreamJSON/upstreamRawBytes path (chat, embeddings, images, audio) via retry.go's retryPolicy.do, which now looks up an optional attemptRecorder from ctx and invokes it once per retry attempt (attempt-accounting: a request retried twice before succeeding reports three attempts, not one). - routes_unified.go's runUnified (chat/embeddings, streaming and non-streaming alike — both funnel through the same upstreamJSON call). - routes_media.go's three media handlers (images.generations, audio.speech, audio.transcriptions). - routes_passthrough.go's proxyUpstream (native provider passthrough) — a single un-retried attempt, provider-level only: the upstream model lives in the response body, read only after the attempt resolves. The attemptRecorder is threaded via context (providers.go's withAttemptRecorder/attemptRecorderFromContext), not a new function parameter on every call site — proxyUpstream and retryPolicy.do are both shared with paths that must NOT record provider accounting (registry.go's discovery listModels calls, and the MCP/A2A target proxy, mcp_a2a.go's handleTargetProxy), so scoping via context keeps their signatures unchanged and those callers correctly opt out by simply never wrapping their context. |
||
|
|
ad5adbcc1d |
docs(readme): handshake fallback, legacy-SSE exclusion, protocol allowlist (SF5)
Rewrote the federated POST /mcp bullets to document the review round 2 fix batch: per-backend timeouts (20s tools/list, 120s tools/call), the bare-first-with-fallback session handshake and exactly what triggers it (JSON-RPC error or HTTP 4xx, never a network failure or 5xx), the legacy HTTP+SSE-transport exclusion (URL path ending '/sse') and why no handshake can fix it, the loud 'no MCP server reachable' error when every attempted server fails, non-2xx/ empty-envelope rejection, the protocol-version allowlist, and the new 405 for a disallowed method on /mcp. Extended the admin-API GET /admin/api/targets bullet, the total-scope accounting list, and the rejected-before-quota bullet to include the federated endpoint. Architecture diagram gains a Federated branch node (render-checked with mmdc). |
||
|
|
12d55e66ef |
test(integration): mocktool JSON-RPC + federated /mcp coverage (SF4)
mocktool's 'tool' mode gains two federated-/mcp JSON-RPC endpoints:
'/' (lenient — every bare, session-less call succeeds, matching 8 of
11 real servers the live probe found) and '/strict' (session-required
— rejects a bare tools/list/tools/call with a JSON-RPC error until a
prior 'initialize' call's Mcp-Session-Id header accompanies it,
matching readitall/fetch's real behavior). A new 'toolstrict'
MCPServers entry in dynamic.yml.tmpl points at '/strict'.
TestFederatedMCP drives POST /mcp against real Traefik+Yaegi: a
federated tools/list must include both tool_echo (bare path) and
toolstrict_echo (reachable only via the plugin's own
bare-first-with-fallback handshake retry, mcp_federation.go's
mcpBackendCall), and a tools/call to toolstrict_echo must route
correctly with the prefix stripped. Closes the review gap ('only
goroutine in the package, zero real-interpreter coverage') the
go-test-only suite left: the handshake fallback, and the plugin's
goroutine fan-out in mcpFederatedToolsList, now both run for real
under Yaegi, not just against go test's in-process mocks.
Verified: make integration — ok github.com/lukaszraczylo/
traefik-llmgateway/integration 40.113s, exit 0, no FAIL/panic/skip in
the full run.
|
||
|
|
d5a5c3e064 |
chore(admin): regenerate baked admin assets
Regenerated via webui/generate.mjs from a fresh vite build after the resilient-dashboard-polling fix (SF7, previous commit). |
||
|
|
a25ebf0567 |
fix(webui): resilient dashboard polling (SF7)
useDashboardStore.refresh() used Promise.all across overview/usage/ targets: one route rejecting threw before any of the three state fields were assigned, so a single flaky GET /admin/api/targets blanked the already-working Providers and Usage views too. Switched to Promise.allSettled with per-section assignment: a fulfilled result updates its own field, a rejected one leaves the PREVIOUS value in place rather than nulling it. lastUpdated still advances on a partial success (some fresh data did arrive); error names which section(s) failed. A 401/403 on any section still defers to the auth store exactly as before (AdminApiError check unchanged). Added dashboard.spec.ts — the first Pinia store under vitest in this package (a store needs no DOM, so it fits the existing 'node' environment without pulling in jsdom); vite.config.ts's own test-scope comment updated to say so. |
||
|
|
8afb903227 |
fix(mcp): backend timeouts, response validity, session handshake fallback
Review round 2 fix batch for the federated /mcp endpoint, driven by a
live probe against all 11 production MCP servers behind this gateway
through the byte-pipe proxy (session-less tools/list): 8 answer bare,
readitall and fetch reject it and need a session handshake, playwright
is a legacy HTTP+SSE-transport server (400 Missing sessionId) that no
handshake fixes.
MF1: every backend call now runs under its own context.WithTimeout
derived from r.Context() — toolsListBackendTimeout (20s) per fan-out
call, toolsCallBackendTimeout (120s) for tools/call, named constants.
MF2: doBackendJSONRPC rejects any non-2xx upstream status as an error
(previously unchecked — a non-JSON-RPC error body only failed by
accident of not parsing). parseBackendJSONRPC additionally rejects a
validly-JSON body that carries neither "result" nor "error" — the
exact silent-failure shape that would otherwise decode into an
all-zero jsonrpcResponse and hand the client a bare
{"jsonrpc":"2.0","id":1}. mcpFederatedToolsCall carries a
defensive final guard against the same shape before relaying.
MF3: mcpBackendCall is now bare-first-with-fallback — attempt the
bare call; on a JSON-RPC error OR HTTP 4xx (never a network failure,
timeout, or 5xx), retry once via initialize -> capture Mcp-Session-Id
-> retry with the header -> best-effort DELETE close. A legacy
HTTP+SSE-transport server (URL path ending "/sse") is excluded from
federation entirely before ever reaching this — isLegacySSETransportURL,
folded into allowedMCPServerNames — since no handshake fixes a
different transport; it stays reachable via the per-server proxy. When
every attempted server fails, tools/list returns a JSON-RPC internal
error ("no MCP server reachable") instead of a silently-empty list.
SF6: defaultMCPProtocolVersion -> "2025-11-25"; a caller's own
protocolVersion is echoed back only from a small explicit allowlist
(knownMCPProtocolVersions), falling back to the default otherwise —
never echoes an arbitrary caller string as this gateway's own claimed
capability.
SF8: GET/DELETE/etc. on exactly /mcp is now 405 with Allow: POST,
checked before auth (llmgateway.go).
Folded minors: countTargetRequests batches per-target counter writes
for tools/list's whole fan-out into ONE storeIncrMulti call after
wg.Wait() (was one call per server); every "actually contacted" doc
comment now says "attempted" (attribution is attempt-, not
success-, accounting); lastSSEDataLine documents its no-cross-message-
id-correlation limitation; routes_unified.go's stale buildLimitScopes/
withTotalScope comments (which claimed handleAdminAPI calls
buildLimitScopes directly — it never has) are corrected; jsonrpcRequest
documents why its "jsonrpc" field is decoded but never validated.
Tests: ~30 new/updated cases covering per-backend timeout derivation
(via the shorter-of-two-context-deadlines technique, no real 20s/120s
waits), non-2xx rejection, the empty-envelope guard end-to-end,
handshake-trigger classification (table-driven: JSON-RPC error and
HTTP 4xx trigger, HTTP 500 and network failure do not), the full
handshake sequence (bare -> initialize -> retry -> close, asserted
call-by-call), handshake-gives-up-too, all-servers-failed, legacy-SSE
exclusion (never contacted, in both tools/list and tools/call),
protocol-version allowlist (all 4 versions + one rejected), 405/Allow,
and best-effort session-close failure never surfacing to the caller.
|
||
|
|
d565c1d4ea |
feat(mcp): federated /mcp aggregation endpoint
New POST /mcp: a single JSON-RPC 2.0 endpoint aggregating every MCP
server the caller's group can reach, for clients built against the
earlier pre-plugin gateway's one aggregated endpoint (pugbot's
mcpclient, agentkit) rather than this plugin's per-server
/mcp/{name}/... proxy.
Unlike the per-server proxy, this cannot be a byte-level reverse
proxy: aggregating N servers' tools/list results, or routing one
tools/call to the single right server, both require actually parsing
the JSON-RPC envelope. The gateway answers initialize/ping locally
and, for tools/list/tools/call, issues its own outbound JSON-RPC
call(s) to the relevant backend server(s) per incoming request — no
session state persisted across separate client requests, matching the
per-server proxy's own complete absence of gateway-side session
tracking. A backend may answer bare JSON or an SSE stream; both are
accepted (parseBackendJSONRPC).
- tools/list fans out concurrently to every allowed server and merges
results, prefixing each tool "<serverName>_<toolName>" — the
underscore convention the earlier agentgateway used and that
pugbot/agentkit already persist in their own tool-id records (e.g.
brave-search_brave_web_search, verified live). A failing/unreachable
server is skipped, not a whole-call failure.
- tools/call resolves the target by LONGEST matching
"<serverName>_" prefix against the caller's allowed servers
(disambiguates a server name that prefixes another), strips it,
forwards, and relays the result/error under the CLIENT's own id. An
unresolvable prefix (unconfigured, or authz-excluded) is a JSON-RPC
invalid-params error, not a 404.
- Reuses Feature B's countTargetRequest for per-target attribution —
every backend server actually contacted increments its own scope.
- notifications/* -> 202 no body; unknown method -> JSON-RPC
method-not-found; malformed body -> JSON-RPC parse error with
id:null (spec-required, not omitted).
No new limits config surface. Tests cover prefix resolution
(table-driven, incl. the ambiguous-prefix case), SSE/JSON backend
parsing, group-restricted aggregation (a denied server is never even
contacted), call routing/prefix-stripping, backend error relay,
unreachable-server handling, unknown-prefix errors, and per-target
counter attribution. README's MCP and A2A section documents the
federation contract and naming convention; the architecture diagram
and the total-scope/rejected-before-quota notes are updated to
include the new route.
|
||
|
|
94d8f90805 |
chore(admin): regenerate baked admin assets
Regenerated via webui/generate.mjs from a fresh vite build after the MCP & Agents tab (feat(webui) commit above). |
||
|
|
7ad573b63e |
feat(webui): MCP & Agents admin tab
New 'MCP & Agents' tab (after Charts) fed by GET /admin/api/targets: two Card panels, each a sortable DataTable (reusing DataTable/ SortHeaderButton — no hand-rolled table markup) listing configured MCP servers and agents with name, url (muted, truncated with a title tooltip), access (Badge per allowed group, muted 'All groups' when unrestricted — the same idiom UsageView's own group Access block already uses), and req/min, req/day, req/month. - types/api.ts: AdminTargetCountersView/AdminTargetView/ AdminTargetsResponse matching admin.go's JSON exactly. - stores/dashboard.ts: targets fetched in the SAME Promise.all/5s poll as overview and usage — one store, one timer, not a third. - lib/target-columns.ts: shared ColumnDef set both DataTables use. - components/TargetsView.vue: SearchInput + useSearchQuery name filter (inline matching, mirroring ProvidersView's own single-field aliasMatches — not every filter needs a lib/*-search.ts module). |
||
|
|
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).
|
||
|
|
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. |
||
|
|
90545df588 | docs: drop reference to local design-doc path | ||
|
|
0530a4a2ff | chore: ignore local docs/superpowers workspace | ||
|
|
87faa9e096 | chore(lint): suppress gosec G704 on operator-configured redis dial | ||
|
|
1c511085d8 | chore(admin): regenerate baked admin assets | ||
|
|
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. |
||
|
|
d99b959ee6 | chore(admin): regenerate baked admin assets | ||
|
|
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. |
||
|
|
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. |
||
|
|
22006c5e25 | chore(admin): regenerate baked admin assets | ||
|
|
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. |
||
|
|
eee4ada0c4 | chore(admin): regenerate baked admin assets | ||
|
|
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. |
||
|
|
9d5cc12b10 | chore(admin): regenerate baked admin assets | ||
|
|
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.
|
||
|
|
3bce93971d | chore(admin): regenerate baked admin assets | ||
|
|
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.
|
||
|
|
46537fa16a | chore(admin): regenerate baked admin assets | ||
|
|
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. |
||
|
|
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. |
||
|
|
dc8663673b | docs: mark project experimental PoC until version 1.0 | ||
|
|
7f500ff803 | fix(webui): shared sort header, coherent group sorting, real expand-state tests | ||
|
|
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). |
||
|
|
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). |
||
|
|
92e9ad0c1e | fix(webui): selectable chip fallback, precise css fixup docs | ||
|
|
b39d4e92c9 |
fix(webui): query-safe expand state, prefixed search, accessible row toggle
Review fix round (5 Important + 3 folded) against the provider-model
accordion (
|
||
|
|
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.
|
||
|
|
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). |
||
|
|
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. |
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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).
|
||
|
|
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. |
||
|
|
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. |
||
|
|
f9fa14ef3f |
fix(limits): enforcement-aware fallback TTL clamp
Re-review round on
|
||
|
|
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 (
|
||
|
|
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 (
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
fcad24c2f0 | fix(ci): real source archive via git export-ignore, current stamping docs | ||
|
|
64eef947a7 | ci: release chain — shared workflows, semver wording, source-only goreleaser, version stamping | ||
|
|
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). |
||
|
|
aee3908bc4 |
test: testify table-driven coverage for low-coverage branches
Lifts all 14 functions the operator flagged as <75% (measured at
|