Commit Graph
23 Commits
Author SHA1 Message Date
lukaszraczylo 1089a2b41a merge: progress-based upstream request timeout, 5m default
# Conflicts:
#	README.md
#	llmgateway.go
#	tools/yaegi-check/main.go
2026-08-23 03:07:48 +01:00
lukaszraczylo c811de3dfb test(yaegi-check): probe the mid-body-stall half of the watchdog too
exerciseRequestTimeout only drove a provider that never sends headers,
so it exercised Transport.ResponseHeaderTimeout alone.
watchdogBody.fire — the interpreted method value handed to
time.AfterFunc, plus context.CancelFunc and sync/atomic's function API
— never ran under that probe, since the request never got far enough
to construct a watchdogBody at all (finding F7, coordinator
adversarial review, 2026-08-23).

Adds hungbody, a provider whose upstream sends headers immediately and
then stalls, and exerciseRequestTimeoutMidBodyStall to drive it.

Verified the two probes exercise different code paths: removing
watchdogBody's construction from upstreamBytes left the existing
header-timeout probe passing unchanged, while the new probe failed
with status 200 instead of 502. Reverted before this commit.
2026-08-23 02:53:53 +01:00
lukaszraczylo e6ce1cc4c5 merge: Prometheus metrics endpoint with admin-key and CIDR auth
# Conflicts:
#	llmgateway.go
#	tools/yaegi-check/main.go
2026-08-23 02:05:06 +01:00
lukaszraczylo 5062017cf1 test(yaegi-check): probe a hung provider aborted under the interpreter
Adds exerciseRequestTimeout: a provider (hungtimeout) with an 80ms
ProviderConfig.RequestTimeout, backed by an upstream that stays silent
for 600ms. The request carries no client-side deadline of its own, so
a pass proves the gateway's own adapter-level timeout aborts the hang,
not a caller-supplied context.

This complements the existing slowProviderName probe, which proves a
CLIENT-set deadline classifies correctly. It does not prove the
adapter's own timeout mechanism runs correctly interpreted, since it
never exercises it without client help.

Verified the probe is not a no-op: temporarily removing
Transport.ResponseHeaderTimeout from newAdapterHTTPClient made make
yaegi-check fail with status 200 instead of 502. Reverted before this
commit.

timeoutProviderModel uses a distinct id, not testDataWantModel:
reusing it made the new provider win bare-id model resolution and
silently hijacked an unrelated existing probe.
2026-08-23 01:44:54 +01:00
lukaszraczylo d45125ee07 fix(failover): default off, add cost guard, fix adversarial-review findings
Flip FailoverConfig.Enabled to default false. A version upgrade with no
config change must preserve prior behaviour, and failover changes which
provider serves a request. Update tests, README, and the yaegi-check
probe to opt in explicitly.

Add a mandatory cost guard. A candidate is skipped unless its input AND
output cost per token are each no greater than the primary candidate's,
resolved through the same canonical-then-bare pricing path the real
billing code uses. Unknown pricing is never treated as free: a known
primary against an unknown candidate skips, an unknown primary against a
known non-zero candidate skips, everything else that is equal is
allowed. Not configurable.

Fix four blocking findings from adversarial review of commit 5d62479.

F1: openai-type adapters mutate the request map in place to force
stream usage reporting. Reusing one map across candidates let a later
candidate read back an earlier candidate's own injection and leak a
usage-only SSE chunk to a client that never asked for it. Every
candidate now attempts against a fresh copy of the original request.

F2: a streaming candidate that fails before writing anything still
stages SSE-only response headers. Those headers now get cleared before
every candidate attempt, so they cannot survive into a later
candidate's plain JSON response.

F3: the request-health gate used the retry-shaped isTransient
classification, which treats a non-429 4xx as a successful outcome. A
provider with a dead API key was never skipped and was retried forever.
The gate now reuses failoverEligible's own classification instead.

F4: a shared request context that an earlier candidate exhausted was
attributed to whichever candidate happened to run next, opening a
healthy provider's breaker for a timeout that was not its fault. A
candidate is no longer attempted once the shared context is already
done; the loop stops and reports the last real error instead.

Also fix three should-fix findings from the same review.

F5: a failure arriving while the request-health breaker is already open
but not yet past its cooldown window is now a no-op, instead of
resetting backoff and sliding the open window forward on every such
failure.

F6: the request-health gate now records at most one outcome per
candidate per logical request, not one per same-provider retry attempt,
so retry.attempts alone can no longer open the breaker on a single
request.

F7: every candidate's cache entry is now checked before any candidate
is attempted, so a cached failover response no longer costs a round
trip to a still-broken primary first.

F10: the generic "failing over" log line is now rate-limited to once
per 30 seconds during a sustained outage, reusing the existing logGate
type. The 404 loud-log line stays unthrottled, as ruled.

F9 (optional): skip building the failover candidate pool entirely when
failover is disabled.

Correct an overstated test comment on
TestHandleChat_Failover_DiscoveryUnhealthy_StillTried: it proves alpha
is still tried, not that ordering places it second.

Document, but do not fix here, that this plugin's HTTP client sets no
per-attempt timeout, so a provider that accepts a connection and then
hangs will never trigger failover. This is a pre-existing property of
the shared HTTP client, not specific to failover.

Deferred: F8 (surfacing request-health state on the admin dashboard)
touches admin.go, which a sibling branch is also editing concurrently;
left for a follow-up to avoid a merge conflict in a file this change
does not otherwise need.

Every fix above is proven by a dedicated regression test, and each was
verified by reverting the fix in place and confirming the corresponding
test fails before restoring it.
2026-08-23 01:18:58 +01:00
lukaszraczylo 244dd6185f test(yaegi-check): verify label escaping under the interpreter
Add a third configured user whose name contains a double quote, a
backslash, and a newline, then assert GET /metrics renders its
Prometheus-escaped form and never the raw name. auth.go validates a
user name as non-empty only, so this is a real, legal configuration,
not a contrived one.

The compiled suite already proves escapeLabelValue correct
(TestMetrics_LabelValuesWithSpecialChars_Escaped,
TestEscapeLabelValue_InvalidUTF8_Injective), but this codebase's own
history is that a compiled pass has repeatedly said nothing about the
interpreted shape. json.Marshal produces the JSON-escaped literal for
the harness's own hand-built config document, kept separate from the
Prometheus escaping the probe actually verifies in the rendered body.
2026-08-23 00:58:51 +01:00
lukaszraczylo 5d624791fa feat(failover): add cross-provider failover for overlapping models
Add failover.go with the full candidate pipeline. resolveWithCandidates
resolves a request exactly like resolve does, then adds every other
provider sharing the identical bare model id. orderedFailoverCandidates
applies a hard skip for request-path-unhealthy providers, deprioritizes
discovery-unhealthy providers without dropping them, fails open when
every candidate reads unhealthy, and caps the list at maxAttempts.

Add requestHealthTracker, a separate per-pod request-path health signal
fed by the same isTransient/isDeadlineExceeded classification
recordProviderAttempt already uses. It never widens the existing
discovery circuit breaker, per that breaker's own documented scope.

Rewrite runMeteredCall (routes_unified.go) to loop over candidates
instead of resolving once. Each attempt rebinds the attempt recorder to
its own provider, recomputes the cache key before the response is
cached, and re-injects the alias echo key an adapter may have deleted on
a prior failed attempt. Failover never runs once any byte has reached
the client, since statusTrackingWriter already tracks that state.

A failure falls through on everything except HTTP 400, matching the
brief exactly. A 404 failover logs loudly, naming both providers and the
model, since it usually signals a real configuration mistake.

Add FailoverConfig (Config.Failover): an Enabled pointer defaulting to
true, and a MaxAttempts bound between 1 and 10, defaulting to 3. A
single-provider deployment, or one with no overlapping bare model ids,
behaves exactly as before regardless of this setting.

Add a yaegi-check probe (tools/yaegi-check/main.go) driving two
providers under the real interpreter, one failing and one serving,
proving the candidate loop runs correctly interpreted.

Update README.md with a Failover section and correct the Retry
section's stale claim that no cross-provider failover exists.

Media routes (images, audio speech, audio transcriptions) do not get
failover in this change. Each has its own request-shape handling that
needs its own scoped follow-up.
2026-08-23 00:32:12 +01:00
lukaszraczylo d421f49ff1 test(yaegi-check): verify the metrics endpoint under the interpreter
Compiled tests have repeatedly passed on this codebase while the
interpreted shape failed, so the metrics feature needs its own probe
in this harness, not just go test coverage.

Enable metrics in the harness config, with modelLabel on so the
opt-in provider-model breakdown runs interpreted too. Add
exerciseMetricsRoute, driving four real requests: unauthenticated
from outside the allowed CIDR block returns 401, a non-admin key
returns 403, the admin key returns 200 with the expected TYPE lines
and live provider and model data in the body, and a keyless request
from inside the allowed CIDR block returns 200.

Run the new probe last, after the breaker probe settles, since GET
/metrics triggers the same maybeRefresh call every route does and
the breaker probe's own hit counting is timing-sensitive.
2026-08-23 00:19:09 +01:00
lukaszraczylo 208084ac83 merge: serve the Anthropic Messages API inbound
# Conflicts:
#	tools/yaegi-check/main.go
2026-08-22 23:51:03 +01:00
lukaszraczylo 3e1f188426 fix(routes): preserve response bytes and close remaining test gaps
Replace the alias-echo rewrite on the Anthropic passthrough branch
with a byte-preserving field replacement. The prior version decoded
the response into a Go map and re-marshaled it. This silently
corrupted a JSON integer beyond float64's exact range. It also
rewrote 1.0 to 1, HTML-escaped angle brackets and ampersands, and
re-sorted every key. A tool_use block's "input" is arbitrary
model-generated JSON. A long numeric id inside tool arguments could
be silently corrupted on a route documented as passthrough.
rewriteModelField now walks the response with a JSON decoder and
copies every field but "model" as raw bytes.

Add a test with a 20-digit integer, a 1.0 float, and a literal "<"
character inside tool_use input. It asserts the response is
byte-identical to the source apart from the model field.

Add a negative test for the anthropic-beta header allowlist. It
asserts Authorization and Cookie never reach the upstream. It also
asserts the upstream sees exactly one x-api-key value: the gateway's
own key, never the client's. A single Get() call could miss a second,
leaked value, since Set() and Add() interact in a way a naive check
would not catch. The test checks the Values() length explicitly.

Add a test case for the native passthrough route's prompt-cache
billing. It fails if cache_creation_input_tokens or
cache_read_input_tokens drop out of the billed prompt count. This
closes a production billing change that previously carried no
coverage at all.

Decouple response delivery from usage extraction on the passthrough
branch. A response body read failure, or a malformed usage field, no
longer both discard the response and misreport the failure as an
upstream connection error. A read failure still returns an error, now
correctly classified as a translation failure. A malformed usage
field no longer blocks the response at all: the client still gets
it, and the existing zero-usage estimate takes over for accounting.

Return the adapter's own usage value on a chatCompletion error in the
translated path, instead of discarding it. This has no effect while
streaming stays rejected up front. It prevents the same critical bug
from resurfacing once streaming support lands.

Normalize tool_result content to a plain string before applying the
error prefix. The prior version only prefixed a Go string. Anthropic's
array content form, which Claude Code sends, lost the is_error signal
entirely. Absent content now becomes an empty string instead of the
JSON null OpenAI rejects.

Treat "stream": null the same as an absent key. The prior fix for a
non-boolean stream value also rejected null. An SDK that serializes an
unset optional field as null must still get an ordinary non-streaming
response.

Drop a yaegi-check assertion that could not fail. The fixture
configures no cache, so the header it checked was never set regardless
of what the code under test did. A mutation pass confirmed this: it
deleted the line the assertion was meant to guard, and make
yaegi-check still reported OK.

Document that this route now intercepts POST /v1/messages
unconditionally, even under passthroughUnknown, where it previously
fell through to the next handler. Add the note to both the new README
section and Known limitations. Correct the passthrough table's claim
about response bytes to describe the actual guarantee.
2026-08-22 23:45:34 +01:00
lukaszraczylo 92b1af0705 test(yaegi-check): exercise the discovery circuit breaker interpreted
Add a breaker probe to the yaegi-check gate, alongside the existing
handler coverage rather than replacing it. Drives a provider whose
/v1/models 401s, the xiaomi production case, through the full closed to
open to half-open to closed cycle against the real interpreter.

This closes a gap adversarial review found: registry.go added a new
interpreted named int32 type with a String method and a new
classification call in recordHealthLocked, and nothing exercised either
under Yaegi. limits.go's matchesSentinel already documents that this
class of divergence between compiled and interpreted behavior is
invisible to go test and only an interpreted probe can catch it.

The probe configures a short breaker window (2 failures, 3 to 6 second
backoff) and a 1 millisecond discovery interval on a dedicated "brk"
provider, so the interval floor never binds and the breaker's own
timing is what the assertions observe. It proves the breaker opens
after sustained failures, suppresses discovery traffic for the whole
open window, and closes again once the provider recovers.
2026-08-22 22:56:32 +01:00
lukaszraczylo 25e73cb585 fix(routes): address review findings on the Anthropic Messages route
Preserve usage when response translation fails after a successful,
billed upstream call. The route previously discarded already-billed
tokens on that path. A client could induce unlimited free usage
against any budget via a truncated tool-call response.

Reject a streaming request with 400 instead of 501. Check it before
the rate limiter runs. A 501 made anthropic-sdk-python retry the
request, since it retries any status of 500 or higher. Each retry
also counted against the caller's rate limit before this fix.

Preserve block order when translating an Anthropic message to
OpenAI's shape. A tool_result block before other content must stay
before it. OpenAI otherwise rejects the translated conversation.

Echo the client's requested model alias in both the passthrough and
the translated response paths. The passthrough branch previously
returned the upstream's own model id instead.

Forward the anthropic-beta header to an anthropic-type upstream
through an explicit allowlist. Never forward Authorization,
x-api-key, Cookie, or hop-by-hop headers. Let the adapter's own
credential injection win regardless.

Bill Anthropic's prompt-cache tokens. anthropicUsagePayload now reads
cache_creation_input_tokens and cache_read_input_tokens too. It folds
all three into the billed prompt count. This applies to this route,
the existing outbound Anthropic path, and the native passthrough
route.

Extract the metered-request pipeline shared by the unified route and
this route into runMeteredCall. Thread an envelope writer through it
so each route answers in its own error shape. This removes the
duplication the original brief warned against. It also lets the
rate-limit and body-admission error paths answer in this route's own
shape.

Treat a non-boolean "stream" value as a streaming request. A string
or number value previously bypassed the streaming check silently.

Emit max_completion_tokens instead of max_tokens for OpenAI's
reasoning models. Those models reject max_tokens with a 400.

Preserve tool_result.is_error by prefixing string content with
"Error: ". OpenAI's tool message carries no error field of its own.
Log a warning when the thinking field is dropped instead of
discarding it silently.

Classify a response-translation failure distinctly in the log. An
operator must never see a genuine translation bug reported as an
upstream connection error.

Document the route in README.md. Cover both auth headers, the
non-streaming limitation, and which providers translate versus pass
through. Add the reviewer's yaegi-check probes to
tools/yaegi-check/main.go, adjusted for the fixes above.
2026-08-22 22:48:50 +01:00
lukaszraczylo 9dc67dedbb feat: add anonymous usage reporting, matching traefikoidc
Release builds send one anonymous "plugin loaded" ping per process, so
the project can see roughly how many installs exist and which versions
are in use. The payload is project name, version and timestamp. Nothing
else leaves the pod: no identifiers, no hostname, no configuration, no
provider names, no keys, no request data.

Three things keep it from firing when it should not. Builds from source
carry the devPluginVersion sentinel and stay silent, so developer
checkouts and every test run never phone home. The ping is sent only
after the plugin constructs successfully, so a rejected configuration is
never counted as an install. A sync.Once holds it to one per process,
because Traefik calls New once per route and oss-telemetry does not
deduplicate on the client side.

Operators of a release build opt out with DO_NOT_TRACK=1,
OSS_TELEMETRY_DISABLED=1, or TRAEFIK_LLMGATEWAY_DISABLE_TELEMETRY=1.
README.md documents the full payload and the opt-out; a plugin that
phones home without saying so in its own README would be indefensible.

oss-telemetry is the plugin's first non-stdlib RUNTIME dependency, which
has consequences for the Yaegi gate:

- vendor/ is no longer excluded from yaegi-check's GOPATH copy. It was
  excluded on the reasoning that it held only test-only testify and
  _test.go files are never interpreted. That reasoning no longer holds:
  leaving vendor out would make the interpreted copy fail to resolve an
  import the real plugin tree resolves fine, so the gate would stop
  representing production.

- yaegi-check now stamps a fake release version into its GOPATH copy so
  New's telemetry gate is PASSED rather than short-circuited, and the
  vendored package is actually called under the interpreter. Import
  resolution alone is not coverage; a cross-package call into interpreted
  third-party code is its own risk. Telemetry is suppressed by env vars
  set before anything is interpreted, so the gate never emits a real
  ping.

- A new assertion reads the version back through GET /admin/api/overview.
  Without it a silently failed stamp would leave the dev sentinel in
  place and the harness would still print OK while covering nothing.
  Verified by disabling the stamp and watching the gate fail.

shouldSendTelemetry is split out from sendStartupTelemetry so the
decision is table-testable without a network, since oss-telemetry's
endpoint is deliberately not overridable from consuming code.

Also brings the version-gated documentation up to date, since the same
README edit touches it. The latest release is v1.0.3, so the pre-1.0
experimental warning has met its own stated condition and is replaced by
a semver stability statement in README.md and dropped from
.traefik.yml's catalog summary. The catalog now serves the released tags
(verified: the v1.0.3 download endpoint returns 200), so the "not yet
published" and "analyzer has not been run" claims are corrected, the
catalog form of the static config is documented as usable, and
examples/kubernetes.yaml pins v1.0.3 instead of v0.1.0.
2026-08-22 18:45:36 +01:00
lukaszraczylo de42192426 refactor(mcp): restore errors.Is for the oversize-response sentinel
Federation's tools/call oversize branch matched a string marker with
strings.Contains instead of wrapping a sentinel. The stated reason was
that no gate exercised this path under Yaegi, so errors.Is was unproven
here.

The caution was misplaced. errors.New returns a compiled
*errors.errorString and fmt.Errorf a compiled *fmt.wrapError, so
declaring the sentinel variable in interpreted code leaves the entire
chain errors.Is walks compiled. Neither documented trap applies: the
matchesSentinel trip-wire covers a plugin-declared error TYPE, and the
comma-ok trap needs a plugin-declared interface. retry.go has matched
errRequestBuildFailed this way through a multi-%w wrap since v0.2.0.
The string check was also strictly worse: err.Error() is itself an
interpreted-to-compiled dispatch, so it verified nothing extra, and
matching on text that partly comes from the backend admits false
positives a sentinel cannot have.

Rather than settle this by argument, tools/yaegi-check now drives POST
/mcp tools/call against a backend whose response exceeds the cap, and
asserts the interpreted plugin reports "response too large". Breaking
the match makes the gate fail with "upstream error", so the probe has
teeth. This closes the objection permanently instead of answering it in
a comment.

Also in doBackendJSONRPC, check the HTTP status before the size cap. A
backend failing with a large HTML error page now reports "upstream
returned HTTP 500", which is actionable, rather than sending the
operator after a payload-size problem that does not exist.

Two comment corrections carried forward from review: the fan-out
semaphore is not the same shape as limiter.spawnTokens (spawnTokens
drops work via a default case, this one blocks and must never skip a
server), and the shared fan-out deadline gives later batches the
remaining budget, not a fresh one, so uniformly slow backends yield a
partial list.
2026-08-22 15:38:35 +01:00
lukaszraczylo 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.
2026-08-22 02:13:50 +01:00
lukaszraczylo 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.
2026-08-22 02:12:29 +01:00
lukaszraczylo 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.
2026-08-22 01:35:21 +01:00
lukaszraczylo 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.
2026-08-22 01:35:03 +01:00
lukaszraczylo 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.
2026-08-22 00:32:00 +01:00
lukaszraczylo 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.
2026-08-22 00:00:16 +01:00
lukaszraczylo 655bbcbf2c feat(webui): vue admin panel source
Vue 3 + TypeScript + Pinia + Tailwind v4 + shadcn-vue + Chart.js admin
panel source under webui/, built with Vite. Views: Overview (providers,
redis/cache/retry status, model aliases), Usage (per-user/per-group/total
tables with tokensIn/tokensOut/cost + limits), Charts (scope selector,
req/tokens(stacked in-out)/cost metric tabs, 24h/30d/12mo window
switcher, fed by GET /admin/api/usage/history). Pinia stores: auth
(sessionStorage key gate, x-api-key header, 401/403 re-prompt),
dashboard (5s poll), history (fetch-on-selection + 30s refresh).

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

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

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

Hygiene: webui/go.mod walls webui/ off from the repo root's `go build`/
`vet`/`test ./...` (npm's node_modules bundles a stray .go file that
would otherwise be swept in); tools/yaegi-check/main.go excludes webui/
from its GOPATH copy for the same reason. webui/ export-ignored
(.gitattributes) from release source tarballs; webui/node_modules/
gitignored. tsconfig.app.json/tsconfig.node.json's JSONC comments
dropped (TS itself is fine with either; the global pre-commit JSON
validator is not) - no config values changed.
2026-08-21 09:21:41 +01:00
lukaszraczylo f7d9208bde chore(deps): vendor testify
Add github.com/stretchr/testify (test-only) as a direct dependency and
vendor it, matching the traefikoidc precedent: _test.go files are never
Yaegi-interpreted, so vendoring a test-only dependency does not affect the
plugin's runtime under Traefik or the catalog analyzer.

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

Verified: make yaegi-check stays green; go build/vet/test unaffected.
2026-08-20 21:31:42 +01:00
lukaszraczylo 0e73558f5c ci: yaegi catalog-analyzer gate harness
Add tools/yaegi-check, a separate Go module (llmgw-yaegi-check,
never a dependency of the plugin module) that replicates the
Traefik Plugin Catalog analyzer (piceus)'s yaegiMiddlewareCheck:
copy the repo into a temp GOPATH, interp.New + i.Use(stdlib.Symbols),
import the plugin package, eval CreateConfig(), decode the plugin's
real .traefik.yml testData into it via a hand-rolled YAML-subset
parser plus a JSON round-trip, reflect-Call New from outside the
interpreter, then exercise the returned handler with two real HTTP
requests (authenticated GET /v1/models against the testData key, and
the same route unauthenticated). Pins github.com/traefik/yaegi to
v0.16.1, confirmed as Traefik v3.5.0's own vendored version by
downloading its go.mod.

Wire cd tools/yaegi-check && GOWORK=off go run . /Users/nvm/Documents/projects/private/traefik-llmgateway
yaegi-check: OK to run the harness from repo root.

The harness caught a real Yaegi v0.16.1 limitation: calling a struct
FIELD of variadic func type (e.g. modelRegistry.log
func(string, ...any)) with two or more variadic arguments crashes
Yaegi's CFG builder ('index out of range'). The identical call shape
through a method or interface method is unaffected — bisected with a
standalone yaegi repro to confirm the exact trigger before touching
plugin code. Fixed by pre-formatting with fmt.Sprintf at
modelRegistry's three affected call sites (registry.go) so the field
is always called with exactly one variadic argument; documented the
constraint on both modelRegistry.log and limiter.logf so it is not
reintroduced. Verified the gate itself is load-bearing by temporarily
renaming CreateConfig (harness failed non-zero) and restoring it
(harness passed again).

Also fixes two golangci-lint findings in the new harness module
(govet shadow, gosec G301 directory permissions).
2026-08-20 12:16:51 +01:00