# traefik-llmgateway A [Traefik](https://traefik.io) middleware plugin that turns a Traefik instance into a multi-provider LLM gateway: one endpoint, several upstream providers, per-user API keys organised into groups, request/token/cost limits, and a registry plus authenticated proxy for in-cluster MCP servers and A2A agents. It runs in either of two forms from this one codebase — as a [Yaegi](https://github.com/traefik/yaegi)-interpreted Traefik middleware plugin, the same way [`traefikoidc`](https://github.com/lukaszraczylo/traefikoidc) does, configured per-middleware in Traefik's dynamic configuration; or as a standalone compiled binary sitting behind Traefik. The plugin form needs no sidecar and no companion service; the binary form exists because streaming arrives token-by-token there and buffered in the plugin. See [Deployment modes](#deployment-modes). **Status:** stable at **v1.0**. Configuration keys, endpoints, and behaviour now follow semver — breaking changes wait for a major release instead of landing between patches, as they could before 1.0. Feature-complete, all gates green (unit tests, `-race`, `yaegi-check`, integration), and published to the Traefik Plugin Catalog. One behaviour worth knowing before you deploy: [streaming is delivered buffered rather than token-by-token](#known-limitations) under the current Traefik + Yaegi combination — which is exactly what running the [standalone binary](#deployment-modes) instead fixes. ## What it is - A unified, OpenAI-compatible surface (`/v1/chat/completions`, `/v1/embeddings`, `/v1/models`) that translates requests and responses to and from Anthropic's and Google Gemini's own wire formats, so any OpenAI-SDK-compatible client can talk to all three providers through one API shape. - An Anthropic-compatible surface (`POST /v1/messages`) for Anthropic SDK clients — including Claude Code — to talk to any configured provider in Anthropic's own Messages API shape; see [Anthropic Messages API (`/v1/messages`)](#anthropic-messages-api-v1messages). - Native passthrough per provider (`/{provider}/...`) for callers that want a provider's own wire format untouched, with the gateway injecting that provider's own API key and still enforcing auth and limits. - Per-user API keys, organised into groups that control which providers, models, MCP servers, and agents a user can reach, plus request/token/cost limits enforced per user and per group. - A registry and authenticated reverse proxy for MCP servers and A2A agents, so those can sit behind the same gateway, the same auth, and the same group-based visibility rules as the LLM traffic.
Request flow diagram (mermaid — renders on GitHub) ```mermaid flowchart LR Req["incoming request"] --> Route{"route
ServeHTTP: path+method dispatch"} Route -->|"a recognized route:
/v1/models, /v1/chat/completions,
/v1/embeddings, /v1/mcp/servers,
/v1/agents, /mcp (federated),
/mcp/name/...,
/a2a/name/..., or /providerName/...
for a known provider"| Auth["auth (per branch)
identify(): key digest lookup"] Route -->|"no route matches"| Fallback["passthroughUnknown ? next : 404
never calls identify()"] Auth -->|"no match"| E401["401 authentication_error"] Auth -->|"user, group"| Branch{"branch"} Branch --> Models["GET /v1/models"] Branch --> Unified["POST /v1/chat/completions
POST /v1/embeddings"] Branch --> Registry["GET /v1/mcp/servers, /v1/agents"] Branch --> MCP["MCP/A2A proxy"] Branch --> Federated["POST /mcp (federated JSON-RPC)"] Branch --> Passthrough["native passthrough"] Unified --> Resolve["resolve model
registry.resolve"] Resolve -->|"unknown/denied"| E404["404 / 403"] Resolve --> Limit1["limiter.checkAndCount"] MCP --> Limit2["limiter.checkAndCount"] Federated --> Limit4["limiter.checkAndCount"] Passthrough --> Limit3["limiter.checkAndCount"] Limit1 -->|"over budget"| E429["429 / 503"] Limit2 -->|"over budget"| E429 Limit3 -->|"over budget"| E429 Limit4 -->|"over budget"| E429 Limit1 --> Exec["execute: translate + call adapter"] Limit2 --> ExecProxy["execute: reverse proxy"] Limit3 --> ExecProxy Limit4 --> ExecFed["execute: answer locally, or
JSON-RPC call(s) to backend server(s)"] Exec --> Account["account: usage, cost"] Account --> Resp["respond to client"] ExecProxy --> Resp ExecFed --> Resp Models --> Resp Registry --> Resp ```
Every route the plugin recognises is handled entirely inside the middleware — `next` (the router's configured backing service) is only reached when a request matches none of the plugin's routes and `passthroughUnknown` is `true`. ## Deployment modes The same codebase runs in two forms. Pick one; they are not deployed together. | | **(A) Traefik plugin** | **(B) Standalone binary** | |---|---|---| | How it runs | Yaegi-interpreted inside Traefik | compiled process behind Traefik | | Configured by | `Middleware` CR / dynamic config | a YAML or JSON config file | | **Streaming** | **buffered** ([why](#known-limitations)) | **incremental** | | Config hot-reload | automatic, on CR change | restart the process | | Extra moving parts | none | one Deployment + Service + image | | Catalog-supported | yes | build it yourself | **Form (A) is still the default** and nothing about it changed. Choose form (B) for one reason: SSE arrives token-by-token. Traefik hands the interpreted plugin a synthetic `http.ResponseWriter` that does not carry `http.Flusher` across Yaegi's reflect boundary, so every flush the plugin performs is a no-op and the whole response lands at once when the handler returns ([traefik/traefik#10269](https://github.com/traefik/traefik/issues/10269)). Compiled, the identical assertion in `statusTrackingWriter.Flush` (`errors.go`) succeeds against a real `*http.response` and chunks reach the client as they are produced. Everything else is the same code: the same routing, auth, limits, accounting, admin API and MCP/A2A proxy, and the same `literal` / `env:NAME` / `file:/path` [secret forms](#secret-forms). ### Running the binary ```sh make build-binary # -> bin/llmgateway make docker-build IMAGE=registry.example/llmgateway:v1 PLATFORMS=linux/amd64,linux/arm64 ./bin/llmgateway -config /etc/llmgateway/config.yaml ``` Every flag has a matching `LLMGW_*` environment variable. An explicitly passed flag always beats the environment. | Flag | Env | Default | |---|---|---| | `-config` | `LLMGW_CONFIG` | `/etc/llmgateway/config.yaml` | | `-listen` | `LLMGW_LISTEN` | `:8080` | | `-name` | `LLMGW_NAME` | `gateway` (the `llmgw[]` log prefix) | | `-log-level` | `LLMGW_LOG_LEVEL` | `info` | | `-shutdown-timeout` | `LLMGW_SHUTDOWN_TIMEOUT` | `30s` | | `-drain-delay` | `LLMGW_DRAIN_DELAY` | `5s` | | `-health-path` | `LLMGW_HEALTH_PATH` | `/healthz` | | `-ready-path` | `LLMGW_READY_PATH` | `/readyz` | `-log-level` filters **only the binary's own lifecycle lines**. The gateway's own `INFO`/`WARN`/`ERROR` output is written unconditionally to stderr by the root package (`logger.go`) and is never filtered. The config file is the same structure as the plugin's `plugin.llmgateway` block, hoisted to the top level — so a `Middleware` CR's `spec.plugin.llmgateway` can be lifted into a `ConfigMap` unchanged. `.json` is accepted directly. ### Things that differ from the plugin, and will bite you - **`metrics.allowedCIDRs` inverts from closed to open.** This is the one that matters. Behind Traefik, every request's `RemoteAddr` is Traefik's own pod IP — inside the pod CIDR that `allowedCIDRs` is meant to be narrowed to — so the keyless-scrape allowlist starts admitting anyone who can reach Traefik. **Drop `metrics.allowedCIDRs` from a binary config** and require an admin key. The binary logs this warning at warn level on startup whenever `metrics.allowedCIDRs` is non-empty (`metricsCIDRWarning`, `cmd/gateway/main.go`). Full explanation under [Metrics](#metrics). - **An unrecognized config key refuses to start, not silently ignored.** The binary's own JSON decode (after the YAML round-trip, `loadConfig`) uses `encoding/json`'s `DisallowUnknownFields`, so a typo like `alowedCIDRs` for `allowedCIDRs` is a startup error (key matching stays case-insensitive, so `allowedCidrs` still binds to `allowedCIDRs`), not a config that boots with that setting silently off. This applies only to the standalone binary's own decode — the Traefik-plugin form stays on Traefik's own, more lenient decoder. - **Set `responseHeaderTimeout` on the Traefik side, or long generations die at the proxy.** Traefik now talks to this gateway over HTTP as a normal upstream, which means the router's `ServersTransport` timeouts apply — and a non-streaming completion can take **minutes** to produce its first response header. Set it explicitly to `0` (no limit) or to something longer than your slowest generation: ```yaml apiVersion: traefik.io/v1alpha1 kind: ServersTransport metadata: name: llmgateway spec: forwardingTimeouts: dialTimeout: 10s responseHeaderTimeout: 0 # a long completion returns no header for minutes idleConnTimeout: 90s ``` Do not set `idleConnTimeout` low either: it reaps pooled connections between requests, but a short value churns them under streaming load. The correct per-request bound is the gateway's own [`requestTimeout`](#request-timeout) — a progress-based watchdog, five minutes by default — not a proxy deadline. - **Config changes need a restart.** Traefik rebuilds the plugin automatically on a CR change; a binary has no such hook. Roll the pods by putting a checksum of the `ConfigMap` in the Deployment's pod annotations, so an ArgoCD sync restarts them. There is deliberately no SIGHUP and no file watcher: a rebuild re-runs the synchronous discovery warm-fill, and a single-file watch never fires on a `ConfigMap` update anyway (the kubelet swaps the whole directory's symlink). **The users file is the exception and needs none of this.** It still hot-reloads on its own 5-second mtime poll from the request path, exactly as in the plugin form. Do not add a watcher for it — it would race the existing one. - **Provider keys resolve once, at construction**, in both forms. Rotating one means a restart even though the mounted file changed. Only file-sourced *user* keys re-resolve on the users-file reload. - **Mount the same secret volumes.** A `file:/llmgw/providers/openai` `apiKey` reads that exact path in the binary too. Reproduce the volumes, or the gateway fails construction. - **`passthroughUnknown` has no meaning.** Traefik is in front of this process, not behind it, so there is no downstream. The binary logs a warning and returns a clean OpenAI-shaped 404 for unknown routes. Route non-gateway paths in Traefik instead. ### Health, readiness and shutdown The binary adds `GET /healthz` and `GET /readyz`; the gateway package itself has no health route. Liveness is unconditional — making it depend on a provider or on Redis would turn any upstream outage into a restart loop. Readiness is true from a successful construction until shutdown begins, and deliberately does **not** depend on discovery or Redis: warm-fill failures are non-fatal by design, and `redis.failOpen` means a Redis loss degrades to in-process counters rather than failing requests. Startup blocks on a synchronous discovery warm-fill for every provider with `discovery: true`, which on a large provider set runs for **minutes** before the listener opens. Size a `startupProbe` for it rather than a short readiness `initialDelaySeconds`, or the pod flaps through every rollout. Shutdown fails readiness first, waits `-drain-delay` so the load balancer drops the endpoint, then stops accepting and waits up to `-shutdown-timeout` for in-flight requests. `Shutdown` *waits for* handlers rather than interrupting them, so that timeout is really "how long before a long generation is abandoned" — raise it and `terminationGracePeriodSeconds` together where long streams matter. Neither probe path should be routed from a public Traefik entrypoint. If a provider is ever named `healthz`, rename it or move the probe with `-health-path`; the binary matches its own paths first. ### Telemetry Unchanged, and no new flag: `oss-telemetry` reads `DO_NOT_TRACK`, `OSS_TELEMETRY_DISABLED` and `TRAEFIK_LLMGATEWAY_DISABLE_TELEMETRY` from the process environment, so setting one on the Deployment works exactly as it does on the Traefik pod (see [Anonymous usage reporting](#anonymous-usage-reporting)). The binary logs which opt-out is set at startup. A locally built binary carries the `0.0.0-dev` sentinel and never pings regardless. ```yaml env: - name: DO_NOT_TRACK value: "1" ``` ## Quickstart ### Static configuration The plugin is in the Traefik Plugin Catalog, so the catalog form of static configuration works: ```yaml experimental: plugins: llmgateway: moduleName: github.com/lukaszraczylo/traefik-llmgateway version: v1.0.3 ``` You can also load it as a [`localPlugin`](https://doc.traefik.io/traefik/plugins/#defining-a-local-plugin), mounting this repository's source into the Traefik container at `/src/github.com/lukaszraczylo/traefik-llmgateway`. That form tracks a branch instead of a pinned release, which suits development and any deployment that wants to follow `main` — see [`examples/docker-compose.yml`](examples/docker-compose.yml) for a working, tested setup that does exactly this: ```yaml # traefik static config (traefik.yml / --experimental.localPlugins.*) experimental: localPlugins: llmgateway: moduleName: github.com/lukaszraczylo/traefik-llmgateway ``` The catalog form above pins a released tag instead, which is the better default for production: a pod restart cannot pick up a change you have not deployed on purpose. ### Dynamic configuration A minimal single-provider middleware, one group, one user: ```yaml http: middlewares: llmgateway: plugin: llmgateway: providers: openai: type: openai apiKey: "env:OPENAI_API_KEY" models: ["gpt-5-mini", "gpt-4o"] groups: default: providers: [] # empty = every configured provider is allowed users: inline: - name: alice group: default apiKey: "env:LLMGW_KEY_ALICE" ``` > Write an "allow everything" group as `providers: []` (an explicit empty > list), not a bare `{}`. See the note at the top of > [`examples/kubernetes.yaml`](examples/kubernetes.yaml) — confirmed > against Traefik's **file provider**, twice, with this exact plugin build: > a bare `default: {}` fails plugin construction with > `expected a map, got 'string'`; `default: {providers: []}` loads cleanly. > Not tested against the Docker/ECS label providers or the Kubernetes CRD > provider — no claim either way for those. An explicit empty list means > the same thing as an empty object (all values allowed), so writing it > this way costs nothing even where the bug does not apply. Attach the middleware to a router the same way as any other Traefik middleware. The router's backing service is never actually reached unless `passthroughUnknown: true` and the request matches none of the plugin's routes — a small dummy service (`traefik/whoami`, or any always-up internal service) is enough. Call it: ```sh curl http://llm.example.com/v1/chat/completions \ -H "Authorization: Bearer $LLMGW_KEY_ALICE" \ -H "Content-Type: application/json" \ -d '{"model":"gpt-5-mini","messages":[{"role":"user","content":"hi"}]}' ``` ## Full configuration reference Every limit or visibility list follows one convention throughout: **empty or omitted means unlimited / all**. Field names below are the exact JSON keys the plugin's Go structs expect (`llmgateway.go`); Traefik's dynamic config accepts them as YAML, which decodes to the same JSON shape. ### `Config` (top level) | Field | Type | Default | Semantics | |---|---|---|---| | `providers` | `map[string]ProviderConfig` | — | Required, at least one entry, or the plugin refuses to construct. | | `groups` | `map[string]GroupConfig` | `{}` | Optional. A user references one or more of these groups by name (`UserConfig.group`/`groups`); an unknown reference is a construction error. | | `pricing` | `map[string]ModelPricing` | `{}` | Per-model USD/1M-token overrides, keyed by model id (bare or `provider/model`). Merged over, not replacing, the built-in table (`pricing.go`). Every entry is validated at construction — see [Limits and accounting](#limits-and-accounting). | | `allowUnpricedWithCostBudget` | `bool` | `false` | `false`: a request for a model with no known price — no `pricing` override, no built-in table entry, and no `modelMeta` entry marking it `free: true` — is refused with **402** whenever the caller's user or group has `costPerDayUSD`/`costPerMonthUSD` configured, since that budget could not otherwise be enforced. Applies to the unified routes and to native passthrough when the model is identifiable from the request body. `true` serves the request anyway, billing it as unpriced (cost `0`) — prefer adding the missing `pricing` entry instead. A caller with no cost budget configured is unaffected either way. | | `mcpServers` | `map[string]TargetConfig` | `{}` | MCP server registry; see [MCP/A2A](#mcp-and-a2a). | | `agents` | `map[string]AgentConfig` | `{}` | A2A agent registry; see [MCP/A2A](#mcp-and-a2a). | | `users` | `UsersConfig` | — | Inline and/or file-backed API-key holders. With none configured, every request is unauthenticated and gets 401. | | `redis` | `RedisConfig` | — | Distributed limit-counter backend. Omitted means in-process counters only (per-replica, approximate across multiple Traefik instances). | | `retry` | `RetryConfig` | `{}` (disabled) | Same-provider retry for transient upstream failures — see [Retry](#retry). Omitted or `enabled: false` means no retry: every request makes exactly one upstream attempt, byte-identical to a gateway built before this field existed. | | `failover` | `FailoverConfig` | `{}` (disabled) | Cross-provider failover for a bare model id more than one configured provider serves — see [Failover](#failover). Omitted or `enabled: false` means no failover, byte-identical to a gateway built before this field existed. `enabled: true` opts in; a deployment with no overlapping providers behaves exactly as before regardless of this setting either way. | | `requestTimeout` | `string` (Go duration) | `5m` | Progress-based upstream request timeout, applied to every provider adapter call, native passthrough, and the MCP/A2A target proxy — see [Request timeout](#request-timeout). **Behavior change for every deployment**: before this field existed, no timeout was set at all, so a provider that accepted a connection and then went silent hung the request forever. A provider's own `requestTimeout` (`ProviderConfig`) overrides this. Empty means the default; a value that fails to parse, or parses to zero or a negative duration, is a construction error. | | `cache` | `CacheConfig` | `{}` (disabled) | Opt-in Redis-backed response cache for unified non-streaming chat/embeddings — see [Caching](#caching). Omitted or `enabled: false` means no caching, byte-identical to a gateway built before this field existed. | | `breaker` | `BreakerConfig` | `{}` (every default) | Per-provider discovery circuit breaker — see [Provider health (discovery circuit breaker)](#provider-health-discovery-circuit-breaker). Every field is individually zero-means-default; a provider whose discovery never fails is unaffected regardless of what this block contains. | | `targetHealth` | `TargetHealthConfig` | `{}` (probes off) | Health tracking for MCP servers and A2A agents — see [MCP and A2A](#mcp-and-a2a)'s "Target health" section. Passive recording and exposure stay on regardless of this block; `enabled` gates only the active probe sweep. | | `admin` | `*AdminConfig` | `nil` (disabled) | Read-only admin dashboard — see [Admin](#admin). `nil` or `enabled: false` means the `/admin*` routes are not registered at all. | | `metrics` | `*MetricsConfig` | `nil` (disabled) | Prometheus text-exposition endpoint — see [Metrics](#metrics). `nil` or `enabled: false` means the metrics route is not registered at all. | | `modelAliases` | `map[string]string` | `{}` | Operator-defined alias id → target model id — see [Model aliases](#model-aliases). Omitted or empty means no aliases, byte-identical to a gateway built before this field existed. | | `modelMeta` | `map[string]*ModelMetaConfig` | `{}` | Per-model context-window and cost overrides, keyed by exact `provider/model` or a bare model id — see [Model metadata](#model-metadata). Omitted or empty means no overrides; every model's metadata still resolves through discovery and the built-in table. | | `passthroughUnknown` | `bool` | `false` | `false`: a request matching none of the plugin's routes gets a 404 JSON envelope. `true`: it falls through to the router's own backing service. **Does not cover the three media routes** — see the upgrade note below. | | `maxInFlightBodyRequests` | `int` | `0` (self-tuned) | Caps how many unified/media requests may concurrently be inside their own request-body read+decode step — see [Body-admission limit](#body-admission-limit). `0` self-tunes from the host's own `GOMAXPROCS`; a positive value pins an exact cap instead, clamped to a maximum of 10,000. | > **Upgrading from v0.1**: `POST /v1/images/generations`, `POST > /v1/audio/speech`, and `POST /v1/audio/transcriptions` are now handled by > the gateway itself, unconditionally — see [Image and audio > endpoints](#image-and-audio-endpoints). They no longer fall through to > `next` even when `passthroughUnknown: true`. A v0.1 deployment that > served its own backend on any of these three exact paths, relying on > passthrough to reach it, must move that backend to a different path > before upgrading to v0.2 — the gateway now intercepts these paths first, > every time. Provider, `mcpServers`, and `agents` map **keys** (names) must match `^[a-zA-Z0-9._-]+$` and must not be `v1`, `mcp`, `a2a`, or `admin` — those names are reserved because they would shadow the gateway's own fixed routes (`/v1/...`, `/mcp/{name}/...`, `/a2a/{name}/...`, `/admin/...`); a name that collides is a construction error, not a silently-unreachable provider. A `nil` map *value* under any of `providers`, `groups`, `mcpServers`, or `agents` is also a construction error, never a panic (`providers.go`, `mcp_a2a.go`, `auth.go`). ### `ProviderConfig` | Field | Type | Default | Semantics | |---|---|---|---| | `type` | `string` | — | Required: `openai`, `anthropic`, or `gemini`. `openai` also serves any OpenAI-wire-compatible provider (Grok, DeepSeek, a local vLLM/Ollama gateway, ...) via `baseUrl`. | | `baseUrl` | `string` | Per type: `https://api.openai.com`, `https://api.anthropic.com`, `https://generativelanguage.googleapis.com` | Trailing slash trimmed. | | `apiKey` | `string` | — | Secret form: literal, `env:VAR`, or `file:/path` — see [Secret forms](#secret-forms). An `openai`-type provider resolving to an empty key is a valid **keyless** upstream (no `Authorization` header sent) — useful for a local model server with no auth. `anthropic` and `gemini` reject an empty resolved key at construction. | | `discoveryInterval` | `string` (Go duration) | `1h` | Minimum time between discovery refreshes for this provider. Only meaningful when `discovery: true`. Refresh is checked on incoming requests, not by a background timer, so this is a floor rather than a schedule — see [Model routing](#model-routing). Invalid duration string is a construction error. | | `models` | `[]string` | `[]` | Explicit model ids this provider serves. Combined with any discovered ids. | | `discovery` | `bool` | `false` | Pull the provider's own model-listing endpoint at startup, then no more often than `discoveryInterval` when requests arrive. A discovery failure is logged and non-fatal; construction still succeeds on `models` alone. | | `metadataPath` | `string` | `""` (no metadata capture) | Only read by `openai`-type providers. A second endpoint, fetched alongside `discovery`, that reports per-model context length — for example `/api/v0/models` on an LM Studio server. Must start with `/` when set (checked at construction). See [Model metadata](#model-metadata). | | `passthrough` | `*bool` | `nil` (enabled) | `nil` or `true`: this provider's native passthrough route (`/{name}/...`) stays reachable, unchanged from before this field existed. `false`: the route is disabled — `ServeHTTP` treats it exactly like an unconfigured provider name, falling through to the ordinary unknown-route 404 without even reaching authentication. Does not affect the MCP/A2A target proxy, which has its own routing prefix. | | `requestTimeout` | `string` (Go duration) | Inherits the top-level `requestTimeout` (`5m` if that is also unset) | Overrides the global request timeout for this provider alone, including its native passthrough route — see [Request timeout](#request-timeout). An explicit override always wins over the global default. Empty inherits; a value that fails to parse, or parses to zero or a negative duration, is a construction error. | ### `GroupConfig` A user can belong to more than one group (`UserConfig.group`/`groups`) and can additionally carry a personal `providers`/`models` grant of their own — see [`UserConfig`](#userconfig) for the full contract. In short: a request is authorized when it matches some **single** member group's own `providers` **and** `models` (or the personal grant's own), never a provider from one group combined with a model only a *different* group or the personal grant allows — providers/models from separate grants never merge. Each member group's own `limits` apply as its own, independent limit scope; `mcpServers`/`agents` are unioned across member groups (any one member's empty list makes the union allow-all too); the effective `cache` setting is `false` if any member sets it `false`, else `true` if any sets it `true`, else inherits; `cacheTTL` is the smallest positive `cacheTTL` configured among member groups. A user belonging to exactly one group with no personal grant is completely unaffected by any of this — it behaves exactly as before multi-group support existed. `passthroughPaths` is the one exception to the union rule above: it is **coupled to the provider AND the model**, not unioned across member groups. A native passthrough request to provider `P`, path `X`, and body model `M` is authorized when some **single** grant — a member group's own, or the personal grant, which has none of its own `passthroughPaths` and so borrows the user's *primary* group's (`group`, or `groups[0]` when `group` is empty) — allows `P` **and** `X` **and** (its own `models` list is empty, or `M` matches it). Unioning `passthroughPaths` the same way as the other lists, or checking the model against a *different* matching grant than the one that authorized the path, would both let joining another group (or adding a personal grant) authorize a combination no single grant ever granted — e.g. group `a` (`providers: [beta], models: [m1]`, no path restriction) joined with group `b` (`providers: [beta], passthroughPaths: [v1/chat/completions]`, no model restriction) must still deny `/beta/v1/files` with body `{"model":"m2"}`, even though `a` alone allows the path and `b` alone allows the model. | Field | Type | Default | Semantics | |---|---|---|---| | `limits` | `*LimitsConfig` | `nil` (unlimited) | | | `providers` | `[]string` | `[]` (all) | Glob-matched (`path.Match`) against a provider's configured name. | | `models` | `[]string` | `[]` (all) | Glob-matched, exactly as given — no automatic `provider/` prefix stripping. Both the bare and provider-prefixed forms of a model id are checked, so a pattern like `gpt-*` matches a client request for either `gpt-5-mini` or `openai/gpt-5-mini`. | | `cache` | `*bool` | `nil` (inherit) | Overrides the top-level `cache.enabled` setting for this group's requests — see [Caching](#caching). `nil` inherits the global setting; `false` opts the group out even when caching is globally on; `true` opts the group in, but only when the top-level `cache` block is actually configured (a construction error otherwise — there is nothing to inherit `ttl`/`maxBodyBytes` from). | | `cacheTTL` | `string` | `""` (inherit) | Sets the TTL written when this group's own request populates a cache entry — see [Caching](#caching). It does not scope entries to a group: cache entries are shared across every group that can reach the model (see the Caching section's "User identity is not part of the key" note, which applies to group identity too), so this controls a write's TTL only, not which group can read the entry. Empty inherits the global TTL. A set value must be a valid, positive Go duration (`"30s"`, `"5m"`), and requires the top-level `cache` block to be configured — a construction error otherwise, the same "nothing to inherit from" reasoning as `cache: true` above. | | `mcpServers` | `[]string` | `[]` (all) | Glob-matched against a configured MCP server name. | | `agents` | `[]string` | `[]` (all) | Glob-matched against a configured agent name. | | `passthroughPaths` | `[]string` | `[]` (all) | Restricts which REST path this group's **native passthrough** requests may address — glob-matched (`path.Match`) against the path segment after the provider name (e.g. `v1/files` for a request to `/openai/v1/files`). Empty is allow-all, matching every other field on this table — **not** `["*"]`: `path.Match`'s `*` does not cross `/`, so a literal `["*"]` pattern denies every multi-segment `rest` (almost all real passthrough traffic). Leave this unset to actually allow everything. See [Unified vs. passthrough](#unified-vs-passthrough) for the related model-authorization rule. | ### `LimitsConfig` All six fields default to `0` (unlimited). A negative count, or a cost that is negative, `NaN`, or `±Inf`, is a construction error. | Field | Type | Window | |---|---|---| | `requestsPerMinute` | `int64` | Fixed 1-minute window, UTC. | | `requestsPerDay` | `int64` | Fixed calendar-day window, UTC midnight to midnight. | | `tokensPerDay` | `int64` | Fixed calendar-day window, UTC. | | `tokensPerMonth` | `int64` | Fixed calendar-month window, UTC. | | `costPerDayUSD` | `float64` | Fixed calendar-day window, UTC. | | `costPerMonthUSD` | `float64` | Fixed calendar-month window, UTC. | Every window enforces correctly on the in-process fallback too, not only Redis — see [Limits and accounting](#limits-and-accounting)'s "Fallback" bullet for the retention floor that keeps `tokensPerMonth`/`costPerMonthUSD` a true calendar-month budget rather than a rolling one when Redis is absent or down. A user's own `limits`, when set, are checked **in addition to** their group's limits, not instead of them — a request is refused the moment it breaches whichever of the two is tighter. A user with no `limits` of their own is governed purely by their group's — see [UserConfig](#userconfig). ### `UsersConfig` | Field | Type | Default | Semantics | |---|---|---|---| | `file` | `string` | — | Path to a JSON users file, merged over `inline` and hot-reloaded — see [Users file](#users-file-format). | | `inline` | `[]UserConfig` | `[]` | Resolved once at plugin construction. | ### `UserConfig` | Field | Type | Default | Semantics | |---|---|---|---| | `name` | `string` | — | Required. | | `group` | `string` | — | The user's primary group. May be left empty when `groups` below names at least one group instead — see `groups` for the "at least one" rule. Any non-empty value here or in `groups` must reference a configured group, or construction fails. | | `groups` | `[]string` | `[]` | Further member groups, alongside `group` — see [`GroupConfig`](#groupconfig) for the full multi-group authorization/limits/cache contract. Effective membership is `group` first (when non-empty), then `groups`, de-duplicated, order preserved. At least one group, from either field, is required — a user naming none at all fails construction. | | `apiKey` | `string` | — | Secret form (see [Secret forms](#secret-forms)); must resolve non-empty and unique across every inline **and** file-sourced user. | | `providers` | `[]string` | `[]` | This user's own personal grant — access beyond whatever their group(s) already allow, glob-matched exactly like a group's own `providers`. Set alone (with `models` left empty), it grants **every** model on those providers — the same empty-`models`-means-all-models contract a group's own `models` carries. The personal grant exists at all only when `providers` is non-empty — leaving it empty/omitted never means "allow everything" on its own, regardless of `models`. | | `models` | `[]string` | `[]` | The personal grant's own model glob, narrowing `providers` above — same semantics as a group's own `models`. Requires `providers` to be non-empty too: a `models`-only personal grant (no `providers`) would reach every configured provider, not just the ones already covered — a construction error naming the user. | | `limits` | `*LimitsConfig` | `nil` (governed by group's limits alone) | When set, enforced alongside — not instead of — every member group's own limits (one independent scope per member group); the request is refused by whichever scope is breached first. The personal grant above carries no limits of its own. | | `admin` | `bool` | `false` | Grants access to the read-only admin dashboard — see [Admin](#admin). Works the same for an inline or a file-sourced user. An admin user is otherwise ordinary: their own keys, group(s), and limits still apply, including to the admin routes themselves. | A user belonging to exactly one group (`group` alone, `groups` empty) with no personal grant (`providers`/`models` both empty) behaves byte-identically to before multi-group support existed. ### `RedisConfig` | Field | Type | Default | Semantics | |---|---|---|---| | `address` | `string` | — | Required when the `redis` block is present at all. `host:port`, RESP protocol — works against Redis, [Valkey](https://valkey.io), or [Dragonfly](https://www.dragonflydb.io). | | `password` | `string` | `""` (no `AUTH`) | Secret form. | | `db` | `int` | `0` | Must be `≥ 0`. Sent via `SELECT` on every new connection. | | `failOpen` | `*bool` | `true` | `true`: a Redis error switches that operation to the in-process fallback store and logs (rate-limited to once per 30s). `false`: a Redis error returns 503 instead of enforcing against a non-shared fallback. | | `poolSize` | `int` | `0` (self-tuned) | Maximum pooled RESP connections. `0` self-tunes from the host's `GOMAXPROCS`, clamped to `4`-`8`; an explicit value always overrides the auto-tuned size (house engineering rule), up to a maximum of `256`. Negative, or above that maximum, is a construction error. | | `tls` | `bool` | `false` | Dial Redis over TLS instead of plaintext, verifying the server certificate against `address`'s host. `false` preserves a plaintext connection exactly as before this field existed; enable it unless the path to Redis is already encrypted some other way (service-mesh mTLS, an stunnel/TLS sidecar, a private link). | ### `RetryConfig` | Field | Type | Default | Semantics | |---|---|---|---| | `enabled` | `bool` | `false` | `false`: no retry, one upstream attempt per request. `true`: retry per [Retry](#retry) below. | | `attempts` | `int` | `1` | Retries performed **after** the first try, not the total try count. `1` (default) allows one retry — two tries total. The maximum, `3`, allows three retries — four tries total. A value outside `1`-`3` is a construction error. | | `backoff` | `string` (Go duration) | `250ms` | Base wait before the first retry; doubles on each further retry, capped at 2s per wait. Invalid duration string is a construction error. | ### `CacheConfig` | Field | Type | Default | Semantics | |---|---|---|---| | `enabled` | `bool` | `false` | `false`: no caching, `/v1/chat/completions` and `/v1/embeddings` behave byte-identically to a gateway built before this field existed. `true`: caching per [Caching](#caching) below — requires `redis` to be configured too, or it silently stays off (one warning logged). | | `ttl` | `string` (Go duration) | `5m` | How long a cached response stays valid. A malformed duration string is a construction error. Zero or negative is accepted, not refused — construction logs one warning, since the store floors it to a 1-second TTL rather than actually disabling the cache; set `enabled: false` to turn caching off. | | `maxBodyBytes` | `int` | `1048576` (1MiB) | Largest response body still eligible for caching; a larger one is skipped (never stored, never an error). Maximum accepted value is `8388608` (8MiB) — above that is a construction error. | ### `BreakerConfig` | Field | Type | Default | Semantics | |---|---|---|---| | `failureThreshold` | `int` | `3` | Consecutive failed discovery refreshes that open the breaker — see [Provider health](#provider-health-discovery-circuit-breaker). `0` uses the default. Must be between `1` and `100`, or construction fails. | | `openDuration` | `string` (Go duration) | `1m` | Base backoff a newly opened breaker waits before its first half-open probe; doubles on every further failed probe, capped at `maxOpenDuration`. Invalid or non-positive duration string is a construction error. | | `maxOpenDuration` | `string` (Go duration) | `6h` | Ceiling on the backoff `openDuration` doubles into. Must be `>= openDuration` and no more than `24h`, or construction fails. Deliberately longer than the default `discoveryInterval` (1h) — see [Provider health](#provider-health-discovery-circuit-breaker) for why a value at or below `discoveryInterval` suppresses nothing. | ### `TargetHealthConfig` Configures the opt-in active probe sweep for MCP servers and A2A agents — see [MCP and A2A](#mcp-and-a2a)'s "Target health" section for the full contract. **Passive recording from real proxied traffic, and exposure through `GET /admin/api/targets` and the `llmgateway_target_healthy` gauge, stay on unconditionally, regardless of this block.** `enabled` gates only the background probe sweep. | Field | Type | Default | Semantics | |---|---|---|---| | `enabled` | `bool` | `false` | `false`: no MCP server or A2A agent is ever proactively contacted for health purposes. `true`: the active probe sweep runs, at most once per `probeInterval`, triggered by `/metrics`, `GET /admin/api/targets`, `GET /v1/mcp/servers`, or `GET /v1/agents` — never a timer. | | `probeInterval` | `string` (Go duration) | `60s` | Minimum time between sweeps. Must be between `10s` and `1h`, or construction fails. | | `failureThreshold` | `int` | `3` | Consecutive failed observations (probe or passive traffic) that mark a target unhealthy. `0` uses the default. Must be between `1` and `100`, or construction fails. | ### `AdminConfig` | Field | Type | Default | Semantics | |---|---|---|---| | `enabled` | `bool` | `false` | `false`: the `/admin*` routes are not registered at all — a request to any of them falls through to the plugin's existing 404/`passthroughUnknown` handling like any other unrecognized path. `true`: the read-only dashboard per [Admin](#admin) below. | | `stats` | `*AdminStatsConfig` | `nil` | Opts into two counter families that are NOT part of the always-on-with-admin set — see [Statistics: always-on vs opt-in](#statistics-always-on-vs-opt-in). `AdminStatsConfig` has no `enabled` field of its own; the block is either present (with each family opted in individually below) or omitted entirely. Omitted (`nil`, the zero value — not an empty `{}` block): both opt-in families stay off, byte-identical to a deployment predating this feature. | ### `AdminStatsConfig` (`admin.stats`) | Field | Type | Default | Semantics | |---|---|---|---| | `userModel` | `bool` | `false` | Per-(user, model) usage breakdown (the `umodel` counter family). Cardinality grows with users × models actually used, unlike every always-on family — an operator opts in explicitly. Gates `GET /admin/api/usage/totals?kind=usermodel\|modeluser` (`404` when off) and the Spend page's attribution drill-down past group→user. | | `latency` | `bool` | `false` | Per-bucket upstream latency histograms (the `ld00`..`ld13`/`lt00`..`lt13` counter families), riding the existing per-request write batch. Gates the percentile fields on `GET /admin/api/performance` (present only when on; `attempts`/`failures` are always present regardless). | ### `MetricsConfig` | Field | Type | Default | Semantics | |---|---|---|---| | `enabled` | `bool` | `false` | `false`: the metrics route is not registered at all. `true`: the Prometheus text-exposition endpoint per [Metrics](#metrics) below. | | `path` | `string` | `/metrics` | Overrides the served path. Must start with `/`, or construction fails. A path at or under a reserved route prefix (the admin dashboard, `/v1`, `/mcp`, `/a2a`) is accepted with a construction warning — see [Metrics](#metrics). | | `allowedCIDRs` | `[]string` | `[]` (no bypass) | CIDR blocks whose source address may scrape without an admin key — e.g. `10.42.0.0/16` for a cluster's pod network. **Never use a whole RFC1918 range like `10.0.0.0/8`** — see [Metrics](#metrics)'s own warning for why that specific mistake is live-reproducible, not theoretical. | | `modelLabel` | `bool` | `false` | Adds a `model` label to the provider attempt/failure counters, breaking them out per `(provider, model)` pair. Off by default: a deployment with hundreds of discovered model ids is a series-count explosion waiting to happen. | ### `ModelPricing` | Field | Type | Semantics | |---|---|---| | `inputPerM` | `float64` | USD per 1,000,000 input/prompt tokens. | | `outputPerM` | `float64` | USD per 1,000,000 output/completion tokens. | ### `ModelMetaConfig` (a `modelMeta` entry) Overrides one model's **context window and per-token cost for display** — see [Model metadata](#model-metadata). Distinct from `ModelPricing` above, which drives request-cost **accounting**: `modelMeta` never changes what a request is billed. | Field | Type | Default | Semantics | |---|---|---|---| | `contextTokens` | `int` | `0` (not overridden) | A value of `0` falls through to the next layer (discovery, then the built-in table) instead of overriding with an unknown value. Must not be negative. | | `inputCostPerMTokMicroUsd` | `int64` | `0` (not overridden) | Input cost per 1,000,000 tokens, in micro-USD (1 USD = 1,000,000). `0` falls through to the next layer unless `free` is set. Must not be negative. | | `outputCostPerMTokMicroUsd` | `int64` | `0` (not overridden) | Output cost per 1,000,000 tokens, in micro-USD. Same fallthrough rule as `inputCostPerMTokMicroUsd`. | | `free` | `bool` | `false` | `true` sets both cost fields to an explicit, known zero — distinct from leaving them at `0`, which means "not overridden here, keep looking." Rejected at construction when combined with a non-zero cost field (contradictory). | ### `TargetConfig` (an `mcpServers` entry) | Field | Type | Semantics | |---|---|---| | `url` | `string` | Required; must parse with an `http` or `https` scheme, checked at construction. | ### `AgentConfig` (an `agents` entry) | Field | Type | Default | Semantics | |---|---|---|---| | `url` | `string` | — | Required; same URL validation as `TargetConfig`. | | `card` | `string` | `/.well-known/agent-card.json` | Path appended to the agent's gateway-relative proxy URL for its A2A agent-card listing. Must start with `/` when set, checked at construction — otherwise it concatenates onto the agent name instead of starting a new path segment, reaching the wrong route rather than failing cleanly. | ### Secret forms Every `apiKey`/`password` field accepts one of three forms (`secrets.go`): - **Literal** — the value itself, used as-is. Fine for local/dev; avoid for anything real. - **`env:VAR`** — read from environment variable `VAR` on the Traefik process. Errors if unset or empty. - **`file:/path`** — read from a file, trimmed of surrounding whitespace. Errors if unreadable or the trimmed content is empty. Provider keys, Redis's password, and inline users' keys are resolved **once**, at plugin construction. A file-sourced user's `apiKey` is re-resolved every time the users file's own hot reload actually fires (see below), so it can use `env:`/`file:` forms too and pick up a rotated value without a Traefik restart — but reload is gated on `users.json`'s own mtime alone. Rotating just the *referenced* secret file, leaving `users.json` itself untouched, is not picked up until something else also changes `users.json`'s mtime. ### Users file format One JSON object, one `users` array, mounted from a single Kubernetes Secret (`users_file.go`): ```json { "users": [ {"name": "alice", "group": "engineering", "apiKey": "sk-llmgw-..."}, {"name": "bob", "group": "general", "apiKey": "env:LLMGW_KEY_BOB", "limits": {"requestsPerDay": 100}}, { "name": "carol", "group": "engineering", "groups": ["friends"], "apiKey": "env:LLMGW_KEY_CAROL", "providers": ["gx10"], "models": ["gx10/GLM-5.3-Flash-EXL3"] } ] } ``` Carol belongs to both `engineering` and `friends` (each group's own grant authorizes her independently — see [`GroupConfig`](#groupconfig)), **plus** a personal grant adding exactly one `gx10` model — an addition on top of whatever `engineering`/`friends` themselves already allow, not a cap on her overall access. A file user with the same `name` as an inline user overrides it (the inline entry is dropped from the merged set). Hot reload polls the file's mtime, throttled to once per 5 seconds on the request path, and is best-effort: a reload already in progress is skipped rather than serialising every concurrent request behind it, so a change lands within a few request-driven checks, not necessarily on the very next one. A reload's own error (unreadable file, malformed JSON, a user referencing an unknown group, a duplicate key) is logged and the last good user set is kept — a bad edit to the file never breaks already-authenticated traffic. ## Model routing - A model id in **`provider/model`** form resolves directly against that provider, but only when the part before the slash names an actually configured provider. Any other id — including one that merely contains a slash, such as an upstream's own `uni/deepseek-v4-flash-0731` naming — is treated as a single **bare** id. - A bare id resolves against the first configured provider, in sorted provider-name order, whose known model set (explicit `models` plus any discovered ids) contains it. When more than one provider knows the same bare id, the sorted-first provider wins it and the gateway logs a collision warning once per id; every provider's copy stays reachable through the explicit `provider/model` form regardless of who won the bare id, and `GET /v1/models` lists both the bare (winner's) form and every `provider/model` form. - **On the unified routes** (`modelRegistry.resolve`, used by `/v1/chat/completions`, `/v1/embeddings`, and the three media endpoints), authorization requires both `group.allowsProviderModel` and `group.allowsProvider` for the resolved provider — a model that exists but the caller's group cannot reach returns 403, distinct from a model no configured provider knows at all (404). Native passthrough (`/{provider}/...`) always checks `group.allowsProvider`, and additionally checks `group.allowsProviderModel` (or, for a multi-group/personal-grant caller, `group.allowsPassthroughModelForPath`, which also couples the REST path into the same grant) — against a `model` field peeked from the request body — whenever the group's `models` list is non-empty; see [Native passthrough model authorization](#native-passthrough-model-authorization) and the authorization row in [Unified vs. passthrough](#unified-vs-passthrough). - **Discovery**: `GET {baseUrl}/v1/models` for an `openai`-type provider, `GET {baseUrl}/v1/models` for `anthropic`, `GET {baseUrl}/v1beta/models` for `gemini`. Runs once at plugin construction (bounded to 5s per provider), then again no more often than `discoveryInterval`. A failed refresh is logged and keeps the provider's last-known discovered set — **stale-while-error**, never an empty list just because one refresh attempt failed. **Refresh is request-driven, not a background timer.** The plugin runs inside Traefik's own process, so it deliberately starts no periodic ticker of its own: the interval is checked on each incoming request (`ServeHTTP`), and a refresh is spawned only when it has elapsed. Three consequences worth knowing: - **An idle gateway never refreshes.** With no traffic at all, the discovered set stays as it was, indefinitely. - **A new model upstream takes up to `discoveryInterval` to appear** — an hour by default. Lower `discoveryInterval` on the providers whose catalogues actually change if you want it sooner; it costs one extra model-listing call per provider per interval. - **There is no manual refresh trigger.** The admin API is read-only (`overview`, `targets`, `usage`, `usage/history`); restarting the plugin is the only way to force discovery immediately. This is why `GET /v1/models` can lag a provider that just published a model, and it is behaviour, not a fault. Discovery health itself is visible per provider in the admin dashboard and as `llmgateway_provider_healthy` on `/metrics`. ## Model aliases Operator-defined indirection: a client-facing alias id that resolves to a real target model id. `modelAliases` is empty by default, and an empty map changes nothing about model resolution — this is purely additive. ```yaml http: middlewares: llmgateway: plugin: llmgateway: # ... providers, groups, users unchanged ... modelAliases: # Client-facing name -> real target. The target can itself be # bare ("gpt-5-mini") or provider-prefixed # ("anthropic/claude-sonnet-4-5"); either resolves through the # normal rules in [Model routing](#model-routing) above. aliased/coding: "anthropic/claude-sonnet-4-5" aliased/fast: "gpt-5-mini" ``` - **Swapping the underlying model**: point an alias at a different target and let Traefik's dynamic-config reload apply it — no gateway restart, no client-side change. A client that has always addressed `aliased/coding` keeps working unmodified after the operator moves that alias to a newer model. - **Precedence**: an EXACT alias match wins resolution before any other rule, including `provider/model` prefix splitting — checked first in `modelRegistry.resolve`. The target then resolves through the same rules as any other id (bare, `provider/model`, or a discovered id). Because of this, an alias whose id contains a `/` is rejected at construction if the part before the `/` names an actually configured provider (it would be shadowed by prefix resolution) — see the validation rules below. - **Authorization**: a group may use an alias when its `models` glob matches the **alias name itself**, OR when it already grants access to the **resolved target** — either is enough. `providers` still gates the target's own provider as usual. - **Validation at construction** (a bad alias config fails the same way a bad provider config does — no gateway starts with a broken alias table): - the alias must be non-empty and contain no control characters; - when the alias contains `/`, its prefix must not name a configured provider (would be shadowed by prefix resolution); - the alias must not equal an explicit `models` entry of any provider (ambiguous — which one wins?); - the target must not itself be another alias (no alias chains); - the target must not be empty. A target naming a model that is not yet known (awaiting a provider's first discovery fetch, or simply never configured) is accepted at construction — it resolves lazily. An unresolvable target at request time returns 404 with a message naming both the alias and the missing target. A target that is later discovered and collides with an already-configured alias id is not a construction error either — the alias always wins that resolution, and the gateway logs one warning per such id. - **`GET /v1/models` listing**: every alias the caller's group may use is listed as a model object alongside real models — `id` is the alias, `owned_by` is the target's resolved provider name. An alias whose target does not resolve yet is omitted from the listing until it does. - **Echo behavior**: exactly like the existing provider-prefix alias echo (`__alias`, [Unified vs. passthrough](#unified-vs-passthrough)) — `anthropic`- and `gemini`-type targets build their own response envelope and echo the client's exact alias string back in its `model` field. An `openai`-type target's response is a **verbatim passthrough** of the upstream body, so its `model` field carries whatever the upstream itself returned (typically the bare upstream model id) — **not** the alias — the same documented v0.1 passthrough asymmetry, unchanged by this feature. - **Applies everywhere a model routes**: unified chat and embeddings, `/v1/images/generations`, `/v1/audio/speech`, and `/v1/audio/transcriptions` (including its multipart rewrite — the upstream `model` field receives the resolved target's own upstream id, never the alias, exactly like a provider-prefixed id already does). - **Admin overview**: `GET /admin/api/overview` includes an `aliases` array (`{alias, target}`, sorted by alias) — see [Admin](#admin). No secrets involved. ## Model metadata Per-model context window and per-token cost, resolved for display — `GET /v1/models` and the admin dashboard show what an operator or client actually knows about a model, without inventing a number nobody configured or reported. `modelMeta` (config overrides) is a **separate** config surface from `pricing` (request-cost accounting, see [`ModelPricing`](#modelpricing)), with one exception: a `modelMeta` entry marked `free: true` also drives billing itself (`unifiedCostMicros`) — that request's cost is `0` and treated as known, never a real price the model's bare id happens to share with the built-in LiteLLM-synced table. Every other `modelMeta` field (context window, cost overrides that are not `free: true`) affects only display, never billing. Where billing does look at price, precedence is: `free: true` (`0`, wins outright) → `pricing` overrides → the built-in table. Context and cost each resolve through their own layer stack (`modelmeta.go`'s `resolveModelMeta`), most specific first: 1. **Context**: a `modelMeta` config override → a provider's own discovery-captured context (`metadataPath`, below) → the built-in table → unknown. 2. **Cost**: a `modelMeta` config override (or its `free: true`) → a `":free"`-suffixed model id (see the free-tier note below) → the built-in table → unknown. A field genuinely unknown at every layer stays unknown — never invented — and is omitted from `GET /v1/models`, not sent as a misleading zero. ```yaml http: middlewares: llmgateway: plugin: llmgateway: # ... providers, groups, users unchanged ... modelMeta: # Exact "provider/model" key. "uni/deepseek-v4-flash-0731": contextTokens: 524288 free: true # Bare model id — applies wherever that bare id resolves. "ornith-397b/qwen3.6-27b/btl-4": contextTokens: 262144 free: true ``` - **Alias inheritance**: a [model alias](#model-aliases) inherits its target's fully resolved metadata, unless the alias id itself has its own `modelMeta` entry — that entry's fields win, independently per field (setting only `contextTokens` on the alias still inherits the target's cost). - **Free-tier naming convention**: a model id ending in `:free` (a real provider naming convention some upstreams use for their own free-tier models) resolves to a known, zero cost even with no `modelMeta` entry and no built-in table row for that exact id — ranked above the built-in table, below an explicit `modelMeta` override. Context is unaffected by this rule; it still resolves through the normal layers above. ### Discovery-captured context (`metadataPath`) Set `metadataPath` on an `openai`-type provider to fetch a second endpoint alongside `discovery`, one that reports per-model context length — LM Studio's own, non-OpenAI-compatible endpoint is the supported shape: ```yaml providers: macstudio: type: openai baseUrl: "http://macstudio.local:1234" discovery: true metadataPath: "/api/v0/models" ``` The response's `loaded_context_length` field is preferred over `max_context_length` when both are present — it reflects the context actually usable right now, which an operator can configure smaller than the model's own maximum under VRAM-constrained runtime settings. This means the reported context window can **change** across a discovery refresh (a model reloaded with a different context setting reports a different value) — it is not a fixed, one-time fact the way the built-in table's numbers are. A failed or absent metadata fetch is always non-fatal: no metadata is captured for that refresh, and `discovery` itself is unaffected. ### Built-in table `pricing_data_gen.go` is a generated, checked-in table synced from [LiteLLM's own pricing data](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json), pruned to the provider types this gateway can meaningfully match (`openai`, `anthropic`, `deepseek`, `xai`, `minimax`, `cerebras`, `dashscope`, `moonshot`) and to chat/embedding models only — the full upstream table is over 3,000 entries, most of them for providers this gateway has no adapter for. Run `make pricing-sync` to regenerate it against the current upstream data. This fetches over the network and writes `pricing_data_gen.go` at build time only — the running plugin never fetches anything itself. The generated file's header records a content digest of the fetched bytes, not a date, so a re-run against unchanged upstream data reproduces an identical file with nothing to review. ### `GET /v1/models` extension fields OpenAI-compat-safe extra fields on each model object, present only when known: ```json { "id": "openai/gpt-5", "object": "model", "owned_by": "openai", "context_window": 400000, "context_length": 400000, "max_model_len": 400000, "pricing": {"input_per_mtok_usd": 1.25, "output_per_mtok_usd": 10} } ``` - `context_window`, `context_length`, `max_model_len` — the same integer token count under all three dialect spellings, omitted together when unknown. OpenAI's own schema has no context field, so each client reads whichever vendor dialect it was built against: `context_length` is the OpenRouter spelling, `max_model_len` the vLLM one. A client whose dialect is missing does not ask — it silently substitutes its own default (128k is a common one), so all three are emitted. - `pricing` — `{input_per_mtok_usd, output_per_mtok_usd}`, both floats in USD per 1,000,000 tokens. Omitted when unknown; present with `0` values for an explicitly free model — a known zero, not an absent field. The admin dashboard's `GET /admin/api/overview` exposes the identical resolved values per provider (`providers[].modelMeta`, keyed by upstream model id) and per alias (`aliases[].modelMeta`) — see [Admin](#admin). ## Limits and accounting - Counters use **fixed windows**, keyed by UTC calendar boundaries (the minute, the day, the month) — not a sliding window. Requests-per-minute and requests-per-day counters increment **before** evaluation, so every request counts toward its scopes' rate tracking even if it is ultimately refused by a different scope's limit. - Token and cost budgets are checked against the value **already accumulated** from prior requests, before this request's own usage is known (usage only arrives after the upstream call completes). A request that would start already over budget is refused; a request that starts under budget but pushes the scope over it is still allowed to complete — an accepted at-most-one-request overshoot, not an exactly-enforced cap. - **Accounting** parses usage from the provider's own response: OpenAI-type `usage`, Anthropic `usage` (from `message_start`/`message_delta`), Gemini `usageMetadata`. When a **non-streaming** unified-route response reports no usage at all, the gateway falls back to a rough estimate — `ceil(request-body-bytes / 4)` counted as prompt tokens, no completion estimate — and marks it `estimated`. Estimated usage therefore lands entirely in the `tokin` counter below; `tokout` never moves for an estimated request. A **streaming** unified-route response with no usage is logged and the request itself is still counted, but no token estimate is applied. - **Tokens are tracked in two directions**: every prompt token increments a `tokin` counter, every completion token a separate `tokout` counter — the same split the admin usage API and usage-history API both expose (see [Admin](#admin)). `tokensPerDay`/`tokensPerMonth` limits are unchanged by this: they still enforce one TOTAL budget, read as `tokin + tokout` in a single batched store read at evaluation time. A violation message still says "tokens", regardless of which direction pushed the scope over. - **A synthetic total scope** (`kind: "total"`, `id: "all"`) is counted alongside every request's own user/group scopes on every metered route (unified chat/embeddings, the media endpoints, native passthrough, the per-server MCP/A2A proxy, and the federated `/mcp` endpoint) — the sum of all LLM traffic combined. It carries no limit and is never evaluated: no configuration can throttle it. The admin usage API's `total` row and every `scope=total` usage-history query read this scope. - **An hour window** (`req`/`tokin`/`tokout`/`cost`, UTC calendar hour) is counted alongside minute/day/month on every metered route, purely for charting — no `LimitsConfig` field ever names it, so it is never evaluated against a limit. It is the finest granularity the usage- history API (below) can query. - Cost is `tokens × price`, computed in integer **micro-USD** (1,000,000ths of a dollar) throughout to avoid float drift. Price resolves in order: a configured `pricing` override, then the curated built-in table (`pricing.go`'s `builtinPricing`), then a larger generated table synced from LiteLLM's own published pricing (`pricing_data_gen.go`) — the first of the three that knows the model wins. Every `pricing` override is validated at construction: a negative, `NaN`, or `±Inf` rate is a construction error, not a value that silently defeats a `costPerDayUSD`/`costPerMonthUSD` budget later. An unpriced model (none of the three layers knows it) costs 0 and logs a once-per-model warning (capped at 128 distinct unpriced model names per process lifetime, then one summary warning) — see `allowUnpricedWithCostBudget` (`Config` table above) to refuse such a request outright instead. The curated table is **approximate** — list prices as each provider published them, recorded 2026-08, and not kept in sync automatically. Set `pricing` overrides for billing-grade accuracy. - **Storage**: `redis` configured wires a hand-rolled, stdlib-only RESP2 client (`resp.go`) — no `go-redis`, which is not Yaegi-interpretable — against Redis, Valkey, or Dragonfly. Every counter write (`checkAndCount`, `account`) goes through ONE pipelined round trip for the whole request, regardless of how many user/group/total counters it touches (perf review, 2026-08-21) — a 3-scope request previously paid up to 9 (request counting) or 27 (usage accounting) separate round trips. Semantics are deliberately **at-least-once, never under-counting**: a lost reply after the server already applied the pipeline can cause a resend that over-counts every counter in it by its own delta. This is a fail-*safe* trade-off (more restrictive than reality), never a fail-*open* one. - **Fallback** (no `redis` configured, or a Redis error with `failOpen: true`): counters live in an in-process map — correct for one Traefik replica, only approximate across several, since each replica counts independently. Exact limits across multiple Traefik replicas require the shared Redis. **Enforcement stays correct on the fallback at every window**, including `tokensPerMonth`/`costPerMonthUSD`: a window's counter always outlives its own natural length (`enforceTTLFor`, limits.go), so a month budget is still a true calendar-month budget, never a rolling one, whether Redis is configured or not. - **Usage-history charting is more limited on the fallback**, and the limit differs by window (`memoryStoreMaxTTL`'s 48h ceiling only binds where a window's own `enforceTTLFor` floor sits under it — limits.go): `hour` charts its full default span (48 buckets fit inside the 48h ceiling); `day` only shows its most recent ~2-3 buckets out of the 35 a request can ask for (a day bucket's own key also sits under the ceiling, so it expires long before a 35-day span would need it); `month` charts its CURRENT bucket in full (a month's own floor, ~32 days, wins over the ceiling — the in-progress month's key outlives the whole month), but not any earlier one, since each past month's key already expired ~32 days after its own creation. Redis applies the real hour/day/month retention TTLs directly (no ceiling), so a Redis-backed deployment charts the full span every window supports. - **Retention** (Redis counter key TTL, distinct from a window's own length): a minute key lives 2 minutes, an hour key 48 hours, a day key 35 days, a month key 400 days — long enough for the usage-history API's largest span (48 hourly / 35 daily / 13 monthly buckets) to always find a live key, not just long enough to survive that bucket's own single rollover. **Migration**: none — a deployed process's old, unsplit `tok` counter keys simply expire on their existing TTL and are never read again; nothing needs backfilling. - **Provider/model success rates** (`limits.go`): a separate counter family from request/token/cost accounting above, tracking upstream HEALTH rather than usage — `llmgw:prov:{provider}:attempt:{window}: {bucket}` and `llmgw:prov:{provider}:fail:{window}:{bucket}` per provider, `llmgw:provmodel:{provider}/{model}:attempt:{window}:{bucket}` and the `fail` counterpart per `(provider, model)` pair. `window` is `min` (2-minute TTL) or `day` (35-day TTL) only — no `hour` or `month`: provider health is a now-and-today question, not a billing one. Every upstream HTTP attempt increments `attempt`; only a provider-fault outcome increments `fail` — the SAME transient classification [Retry]( #retry)'s own retry decision uses (connection error, HTTP 429, HTTP 5xx), **plus one addition this counter family alone applies**: a request that hit the gateway's own configured deadline (`context.DeadlineExceeded`) also counts as a failure here, even though retry itself never retries a deadline it already knows has passed. A request the CLIENT canceled (`context.Canceled`) still counts as an attempt only, from both — not the provider's fault either way. A non-429 4xx (the provider answered, just didn't like the request) is never a failure. Retried attempts are all counted individually: a request retried twice before succeeding writes three attempts here (two failures, one success), not one. The admin overview API (see [Admin](#admin)) is the only reader; the write itself runs off the request's own goroutine (fire-and-forget — this is telemetry, and nothing in the request path waits on or gates against it), so it never adds latency to a response, streaming included. That goroutine is bounded (64 in flight per process) and lossy under sustained pressure: a write that cannot start immediately is dropped, not queued — a deliberate trade-off against an unbounded goroutine pile-up if the configured store ever goes slow without going fully down. Enforcement (request/token/cost limits) never goes through this path at all. - **Native passthrough accounts provider-level only, never per-model.** `POST /{provider}/...` has no resolved model at request time — the upstream model, if any, only appears in the response body, read after the attempt has already resolved — so it never writes a `provmodel` counter, only the provider-level ones above. - **Native passthrough usage accounting covers streaming too.** A streamed (SSE) passthrough response is parsed for usage the same as a non-streaming one: the OpenAI-shaped final `usage` chunk (present when the client itself set `stream_options.include_usage`), or Anthropic's `message_start`/`message_delta` events. When neither yields usage — Gemini's own SSE convention is left unhandled, or the stream never reports one — the request-body-size estimate (`ceil(request-bytes / 4)` prompt tokens, `estimated`) covers it instead, so a streamed or malformed passthrough response is never billed zero just for never finishing cleanly. - **Native passthrough request bodies are capped at 32MiB** (`maxPassthroughBytes`, `routes_passthrough.go`) — generous enough for a multimodal payload the unified routes' own narrower caps do not need to accommodate. A request whose declared `Content-Length` already exceeds the cap is rejected with **413** before any upstream call is built; a body with no declared length (chunked transfer) is caught mid-copy instead and also answers **413**, never a silently truncated body forwarded upstream. - **`allowUnpricedWithCostBudget` also applies to native passthrough.** When a caller's user or group has a cost budget configured and the model — read the same way [Native passthrough model authorization](#native-passthrough-model-authorization) reads it, from the request body — has no known price, the request is refused with **402** exactly like the unified routes, unless `allowUnpricedWithCostBudget` is set. ### Body-admission limit The unified `chat/completions`/`embeddings` routes and the three media routes (`images/generations`, `audio/speech`, `audio/transcriptions`) each decode their request body into memory before doing anything else with it — a step that can use several times the body's own byte size, worst case, for an adversarially-shaped payload. To bound how many requests can be doing that at once, each of those routes claims a slot from a small in-process semaphore for exactly the duration of its own read-and-decode step, and releases it immediately afterward — **not** for the rest of the request. Resolving the model, checking the response cache, and the upstream call itself (which can run for seconds to minutes on a real completion) all happen with the slot already released, so this never becomes a ceiling on total concurrent requests. - On exhaustion (every slot in use at that instant), the request gets an immediate **503** with a `Retry-After` header — never queued, never blocked. - The slot count self-tunes from the host's own `GOMAXPROCS` by default; `maxInFlightBodyRequests` (see the `Config` table above) pins an exact value instead when set. - The unified chat/embeddings routes decode against a **4MiB** cap — narrower than the 10MiB the media routes and `audio/transcriptions`' multipart body still use (see [Body limits](#image-and-audio-endpoints) above) — sized to comfortably fit a single inline base64-encoded image in a chat message while bounding worst-case decode memory. - **Native passthrough is not gated by this at all**: it streams the request/response body straight through rather than decoding it into memory, so there is nothing here for this limit to bound (the one exception, a bounded 64KiB peek for model-restricted groups, is already capped independently — see [Native passthrough model authorization](#native-passthrough-model-authorization)). ## Retry Same-provider retry for a transient upstream failure. Off by default (`retry.enabled: false`); every config that predates this field keeps making exactly one upstream attempt per request. See [Failover](#failover) below for the separate, cross-provider mechanism. - **Scope**: every adapter upstream call — chat, embeddings, model listing, and the three media endpoints (`images/generations`, `audio/speech`, `audio/transcriptions`) — across all three provider types. Native passthrough (`/{provider}/...`) and the MCP/A2A proxy are raw reverse proxies and are never retried; the client owns retry semantics there. - **Attempts is retries, not tries**: `attempts` counts retries performed **after** the first try. The default, `1`, allows one retry — two tries total, not one. The maximum, `3`, allows three retries — four tries total. - **Transient classification**: a connection error (dial/EOF/reset), HTTP 429, or HTTP 5xx. Never a non-429 4xx, a translation error, a request that failed to build in the first place (a malformed method or URL fails identically on every attempt), or a context cancellation/deadline. - **Zero-bytes rule**: retry only runs before any response byte reaches the client. A non-streaming call may retry its whole exchange. A streaming call retries only the initial request, up to the point the upstream's headers and status arrive — once the first chunk is forwarded, a stream that then dies mid-flight is not retried, since the client already holds partial data. - **Waiting**: a 429 response carrying a `Retry-After` header that parses as a non-negative whole-second count is honoured, clamped to 30s when it asks for longer. Every other case — no header, an unparseable value (for example the HTTP-date form), a negative value, or a `Retry-After` on a non-429 5xx — falls back to exponential backoff (`backoff × 2^(attempt-1)`), capped at 2s per wait. The two caps are deliberately different: 2s bounds the wait the gateway computes for *itself*, 30s bounds only what an upstream may explicitly *ask* for. Before this was fixed, a `Retry-After` above 2s was discarded and replaced by the ≤2s ladder, so a saturated provider could not ask this gateway to pause for longer than two seconds — it answered an explicit overload signal by retrying sooner than asked. The computed backoff also carries **equal jitter** — each wait is a random value in `[d/2, d]` — so that many requests failing at the same instant do not retry in lockstep against the same upstream. A context canceled or timed out during a wait aborts the remaining retries immediately, rather than blocking out the wait in full. - **Accounting**: only the final attempt's usage is recorded — a failed attempt's body is drained and discarded, never parsed for usage, so it is never double-counted. Request counters still increment once per client request, unchanged. ## Failover Cross-provider failover for a bare model id more than one configured provider serves — for example `whisper-1` configured on both `openai` and `openai-audio`. **Off by default** (`failover.enabled: false` unless set otherwise): a version upgrade with no config change preserves prior behavior exactly, since failover changes which provider serves a request and can shift cost attribution or mask a broken provider key behind an apparent success. Set `failover.enabled: true` to opt in. A deployment with no overlapping providers, or with a single provider, behaves exactly as before regardless of this setting either way, since there is never a second candidate to try. - **Scope**: `POST /v1/chat/completions`, `POST /v1/embeddings`, and `POST /v1/messages` only. The image/audio endpoints (`images/generations`, `audio/speech`, `audio/transcriptions`) are not wired into failover yet — each has its own request-shape handling (multipart rebuild, binary/streaming responses) that needs its own scoped follow-up. - **Eligible candidates**: a bare model id resolved via the collision winner (the same mechanism `GET /v1/models` documents for overlapping catalogs) offers every OTHER configured provider that also serves that id and that the caller's group is authorized to use. A `provider/model` request, or an alias whose target is `provider/model`, is an explicit, exact choice and is never eligible for substitution. - **What falls through**: everything except HTTP 400 — connection failures, timeouts, HTTP 429/401/403/404, and any 5xx. A 400 never falls through: the request is malformed and every provider would reject it identically. A failover past a 404 is logged loudly, always, naming both providers and the model — a 404 usually means a real configuration mistake, not a transient outage. The routine "failing over from X to Y" line is rate-limited to once per 30s during a sustained outage (with a suppressed-count note on the next line), so it cannot itself become a blocking synchronous write on every request; the 404 line is exempt from that limit. - **Cost guard (not optional)**: failover never routes to a candidate priced higher than the provider it is failing over FROM (the first candidate actually attempted, never the cheapest in the list), checked on BOTH input and output cost per token — a candidate cheaper on input but dearer on output is still rejected. Pricing is resolved exactly the way real billing resolves it: the canonical `provider/model` id in `pricing` first, falling back to the bare model id, so a per-provider `pricing` override correctly makes the identical model id cost different amounts on different providers. Unknown pricing is never treated as free: if the primary's price is known and a candidate's is not, the candidate is skipped (an unknown price cannot be proven cheaper); if the primary's price is unknown and a candidate's is known and non-zero, the candidate is skipped too (moving from unbilled to billed is itself a spend increase); both unknown, or both an explicit known-zero price, are treated as equal and allowed. There is no config flag for this — it applies unconditionally whenever failover is enabled. - **The cost guard cannot see `modelMeta`'s `free: true`.** It resolves pricing only through `pricing` and the built-in table — the same source `unifiedCostMicros` actually bills against — deliberately never through `modelMeta` (see [Model metadata](#model-metadata) / `ModelMetaConfig`), since `modelMeta` drives GET `/v1/models` display only and never affects billing. A model declared free the `modelMeta` way is therefore treated as unknown-priced by the cost guard and loses redundancy: it is skipped as a failover candidate whenever the primary has a known price (fail-safe, and logged like any other cost skip). Declare the price through `pricing` (e.g. `{"inputPerM": 0, "outputPerM": 0}`) if you want a genuinely free model to remain eligible as a cheaper failover candidate. - **Never mid-stream**: failover only happens while nothing has reached the client yet. Once the first byte of a response is written, status and body are committed and the request is never retried elsewhere. - **Health signals, kept separate**: the discovery-endpoint health surfaced on the admin dashboard is an ordering hint only — a provider whose `/v1/models` is unhealthy is tried later, never skipped outright, since plenty of providers serve chat completions fine while their model listing is broken. A separate, in-memory, per-pod request-path health signal (not yet exposed on the admin dashboard) is the actual routing gate: a provider is skipped without being called once it has failed three consecutive LOGICAL requests (same-provider retries all count as one outcome, not one each) — everything except HTTP 400 counts as a failure for this gate, including 401/403/404, so a provider with a dead API key is eventually routed around instead of retried forever. - **Accounting**: a failover chain still counts as exactly one client request against `requestsPerMinute`/`requestsPerDay` — never once per attempt. Per-provider attempt/failure counters (the admin dashboard's success-rate badge) attribute every attempt to the provider that actually made it. **Fixed (admin dashboard redesign):** the per-model request counter (`GET /admin/api/usage/models`, `/usage/series?scope= model:...`) used to increment for EVERY candidate a failover chain ran, including one that failed before writing anything — a 3-candidate chain where only the last succeeded used to inflate that model's served-request count by 2 phantom attempts. It now only counts a candidate that either actually served something (a genuine response, even a zero-usage streaming one) or reported nonzero usage (a mid-stream failure that still delivered content) — a candidate that failed with nothing to show for it no longer touches the model counter at all (`routes_unified.go`). Token/cost counters were never affected by this defect: they were already gated on nonzero usage independently. A failed candidate is still visible on its own terms — via `fover`/`attempt`/`fail`, below. - **Caching**: a cacheable response produced by a failover provider is stored under that provider's own cache key, never the first candidate's. Each candidate's own cache entry is checked immediately before that candidate's own attempt, in try order — never for every candidate up front. Checking every candidate's cache before any of them was attempted was tried and reverted: it silently served a DIFFERENT candidate's cached response even while the actual primary was perfectly healthy, which is failover with no failure. The request-path health signal above is what actually keeps a genuinely dead primary from being dialed again — once it is marked unhealthy it is excluded from the candidate list entirely, so a later request reaches the next candidate's cache without the dead one ever being attempted. - **Configurable**: `failover.maxAttempts` caps how many different providers one request will try in total, primary included (default 3, maximum 10) — distinct from `retry.attempts`, which retries the SAME provider. `failover.enabled: false` (the default) turns the whole mechanism off. - **Known limitation — per-attempt bounds do not compose**: `newAdapterHTTPClient` *does* set `Transport.ResponseHeaderTimeout` (from `requestTimeout`, five minutes by default), and `watchdogBody` adds an idle-progress bound on the response body. What it deliberately does not set is `http.Client.Timeout`, which would truncate a healthy long stream. Both existing bounds are therefore **per attempt**, not per request: nothing bounds their sum. With failover and retry both enabled, one admitted request can make up to `failover.maxAttempts × (retry.attempts + 1)` outbound calls — as many as 40 at the configured maxima, 6 with both merely switched on at their own defaults — and each attempt gets its own full `ResponseHeaderTimeout` budget rather than sharing one. The gateway logs the composed worst case at startup when it exceeds 12. The only aggregate bound is whatever deadline the incoming request's context already carries, which in the Traefik plugin form comes from the entrypoint's `respondingTimeouts` and in the standalone binary must come from a fronting proxy. A per-request outbound-call budget is not implemented. ## Request timeout Every provider adapter call and every native passthrough (`/{provider}/...`) request is bounded by a progress-based timeout: aborted once this gateway observes no upstream read progress for the configured duration, never simply because elapsed time crossed a fixed deadline. The MCP/A2A target proxy (`/mcp/{name}/...`, `/a2a/{name}/...`) shares the identical mechanism. The federated `/mcp` endpoint (`mcp_federation.go`) does **not** — see the "Federated MCP" note below. **Behavior change for every deployment, not an invisible bug fix.** Before this feature, an adapter's `*http.Client` set no timeout of any kind: a provider that accepted the connection and then went silent — sent no headers, or sent headers and then stalled mid-body — hung the gateway request forever. Default, when neither `requestTimeout` field is set: **five minutes**. - **Two mechanisms, one config value**: time to the first response header (`Transport.ResponseHeaderTimeout`), and idle time WITHIN the response body once headers arrive. The second is what catches a provider that answers promptly and then stalls mid-stream or mid-JSON-body — `ResponseHeaderTimeout` alone only bounds the wait for headers. - **Progress-based, not total-duration.** A streaming response resets the idle timer on every chunk it forwards, so a long generation that keeps producing tokens completes even if the whole exchange runs for 20 minutes — a total-duration cap was deliberately rejected for exactly this reason. Non-streaming responses share the identical mechanism: a large or slow-but-progressing body is unaffected; only a body that goes quiet for the full timeout is aborted. - **What "progress" means, precisely.** The idle timer resets on bytes read from the *decoded* body — after `net/http`'s own transparent gzip decompression, when the upstream used it. A gzip'd response whose compressed bytes keep arriving on the wire but whose deflate stream yields no decompressed output for the whole timeout window is still aborted: this gateway has no visibility into wire-level progress without a transport or connection wrapper, which is disproportionate at the five-minute default. In the same vein, a streaming forwarder reads one chunk from the upstream, writes it to the client, then reads again — the write is not itself watched, so time spent blocked writing to a slow or stalled client counts against the SAME idle window as a silent upstream. Aborting in that case is still the right call (holding an upstream connection open indefinitely for a stalled client is its own problem), but the failure is not always the provider's fault even though the request is attributed to one for accounting purposes. - **Never retried.** A `ResponseHeaderTimeout` failure surfaces as a `context.DeadlineExceeded`-shaped error, which [Retry](#retry)'s own transient classification explicitly excludes. A mid-body watchdog timeout happens after the retry mechanism has already returned its response to the caller, so it is never in a position to be retried either way. Not retrying a hang is the intended behavior — retrying a provider that just proved it cannot make progress would only waste the same amount of time again. - **Scope and override**: `requestTimeout` (top level) sets the global default. A provider's own `requestTimeout` overrides it — an explicit override always wins. Native passthrough for a provider uses that same provider's resolved value, so a client hitting `/{provider}/...` gets the identical bound as a request through `/v1/chat/completions`. The MCP/A2A target proxy has no per-target override; it always uses the global default. - **Config form and validation**: a Go duration string (`"5m"`, `"90s"`), matching `retry.backoff`/`cache.ttl`'s own convention. Empty means the default. A value that fails to parse, or parses to zero or a negative duration, is a construction error — unlike `cache.ttl`, zero/negative is never accepted here as "no timeout": that would silently reintroduce the exact hang this feature fixes. - **Classification**: a timeout firing is always logged and reported to the client as an upstream failure (`502`, `server_error`), naming whichever provider or target the request was addressed to, and is never classified as a client disconnect (`context.Canceled`) — the two stay distinguishable in every log line. The log wording says "upstream progress stalled", not "provider timed out": for the reason described above (a slow client can trip the same watchdog a slow provider does), the message reports what was observed without asserting which side caused it. **Federated MCP (`POST /mcp`) is bounded differently, not left unbounded.** `mcp_federation.go`'s two direct upstream calls (`doBackendJSONRPC`/`mcpBackendCloseSession`) are not wrapped by the idle-progress watchdog described above — a federated backend that sends headers and then stalls mid-body is not caught by it. They are not unbounded either: both still run through `g.targetClient`, so `Transport.ResponseHeaderTimeout` (the global `requestTimeout`) still bounds the wait for headers, and each call additionally runs under its own fixed `context.WithTimeout` — 20s for `tools/list`, 120s for `tools/call`, 5s for the best-effort session-close DELETE. A worst-case stall there is bounded at whichever of those applies, not indefinite. ## Caching An opt-in, Redis-backed response cache for the unified, non-streaming `POST /v1/chat/completions` and `POST /v1/embeddings` routes. Off by default (`cache.enabled: false`); every config that predates this field keeps calling the upstream on every request. Caching also requires `redis` to be configured — without it, `cache.enabled: true` is silently disabled with one warning logged at construction, never a per-replica in-process fallback: divergent replicas would otherwise serve different cached bodies for the same request. - **What's cacheable**: a non-streaming request to either route, whose upstream response was HTTP 200 and no larger than `cache.maxBodyBytes`. Never cached: a streaming request, native passthrough, MCP/A2A, the image/audio endpoints (see [Image and audio endpoints](#image-and-audio-endpoints)), or any non-200 upstream response. - **Key**: `llmgw:cache:` plus a hex SHA-256 digest of the provider name, the resolved upstream model id, the client's own exactly-as-sent `model` string (`requestedModel`), the endpoint (`chat` or `embeddings`), and the canonical JSON of the request body with `stream_options` and `user` removed. `requestedModel` and the endpoint are both part of the key so that two different alias forms of the same upstream model (`claude-x` versus `anthropic/claude-x`) never collide into one cache entry: a translating adapter (`anthropic`, `gemini`) bakes the client's own alias into the cached response body's `model` field, so a second client using the other alias form would otherwise receive an id it never sent. - **User identity is not part of the key.** Identical requests share one cache entry across every user and group that can reach the model — by design, since a provider's response to an identical request is provider-deterministic and carries no per-user data. - **Hit/miss header**: every cacheable request's response carries `X-Llmgw-Cache: hit` or `X-Llmgw-Cache: miss`, set before the body is written either way. - **Accounting**: a cache hit increments request counters exactly like any other request — `limiter.checkAndCount` runs before the cache lookup — but never touches token or cost counters, and never runs the streaming-response-with-no-usage estimate. Only a genuine upstream call (a miss) accounts real tokens and cost. This means a cache hit is request-quota-counted but token/cost-free. - **Group override**: `GroupConfig.cache` (a `*bool`) overrides the top-level `cache.enabled` setting per group — `nil` inherits it, `false` opts the group out even when caching is globally on, `true` opts the group in. Setting `true` on a group when the top-level `cache` is not enabled — the block omitted entirely, or present with `enabled: false`, both read the same way — is a construction error: there is nothing configured to inherit `ttl`/`maxBodyBytes` from. - **Group TTL override**: `GroupConfig.cacheTTL` sets the TTL written when this group's own request populates a cache entry — it is not a per-group scope. Cache entries are shared across groups, exactly like the "User identity is not part of the key" bullet above: whichever group's request happens to populate (or later refresh) an entry decides that entry's TTL, and a later request from a different group that hits the same entry reads it with the TTL already stored there, unaffected by its own `cacheTTL`. An empty value inherits the global TTL for that group's own writes; a set value must parse as a positive Go duration and requires the top-level `cache` block to be configured, or construction fails. - **Storage**: values are stored via the same hand-rolled RESP2 client (`resp.go`) the limiter's distributed counters use — the identical Redis/Valkey/Dragonfly connection, not a second one. A Redis error during a cache read or write is treated as a miss (logged, rate-limited) and never fails the request; the upstream call still happens and the client still gets a correct answer. ## Provider health (discovery circuit breaker) A per-provider, in-memory, three-state circuit breaker (`closed` → `open` → `half-open`) driven entirely by `discovery` refresh outcomes — never by `/v1/chat/completions` or any other request-path traffic. It exists so a provider whose discovery endpoint fails on every cycle (an expired API key, a typo'd `baseUrl`, an upstream outage) is re-probed LESS often the longer it stays broken, instead of at the same fixed `discoveryInterval` forever, and so the admin dashboard can show that provider as visibly broken instead of indistinguishable from a healthy one. - **States**: `closed` is normal — refreshes run on the provider's own `discoveryInterval`, exactly as if this feature did not exist. `breaker.failureThreshold` (default `3`) CONSECUTIVE failed refreshes open it; any success in between resets the count to zero. `open` skips refresh attempts until a backoff window elapses, starting at `breaker.openDuration` (default `1m`) and doubling on every further failed probe, capped at `breaker.maxOpenDuration` (default `6h`). `half-open` is exactly one probe refresh, deciding whether to close the breaker again (success) or reopen it with a doubled backoff (failure). - **The backoff window is an ADDITIONAL gate on top of `discoveryInterval`, never a replacement for it** — whichever of the two is currently longer wins, and the shorter one is masked entirely rather than merely adding a little extra delay. This means `breaker.maxOpenDuration` only ever reduces re-probe frequency once it EXCEEDS `discoveryInterval`; set no higher, it changes nothing observable and the provider is refreshed at the same cadence a deployment without this feature would already see. The shipped defaults are chosen with this in mind: `maxOpenDuration` (6h) is deliberately longer than the default `discoveryInterval` (1h). The backoff only starts to matter once it exceeds that hourly gate: doubling from `openDuration`'s `1m` default reaches 64m, then 128m, then 256m, then caps at `maxOpenDuration` (6h) from then on. A permanently broken provider under stock config sees roughly 13 discovery attempts on the day the breaker trips (the hourly-gated ramp plus the first few backoff-governed probes), settling to a steady state of roughly 4 attempts a day thereafter (24h ÷ 360m) — down from 24 without this feature. A custom `discoveryInterval` shorter than `maxOpenDuration` keeps this relationship; one set LONGER than `maxOpenDuration` reverts to plain interval-only throttling with no suppression from this feature at all. - **Classification**: any refresh outcome with a non-nil error counts as a failure, including a malformed-`baseUrl` request-build failure — this is deliberately NOT the same classification `retry` above uses for deciding whether to retry a request, since a permanently broken provider must still be able to trip the breaker. A client-canceled context (plugin shutdown, an outer context canceled upstream) is NEUTRAL: it counts as neither a failure nor a success, and never resets or closes an open breaker. - **A provider that never fails is completely unaffected** — every default is chosen so `closed` never diverges from plain `discoveryInterval` gating. - **Scope**: this reflects the DISCOVERY endpoint only. A provider whose model listing 401s while its actual completion endpoint works fine (common behind a proxy that exposes chat but not model listing) is reported unhealthy here despite being entirely usable for real traffic — this signal must never be the sole reason to refuse routing to a provider. - **Per-pod, not distributed.** Each replica learns a provider's discovery health independently — a latency optimization, not a correctness guarantee. Three replicas disagreeing briefly after a provider starts failing is expected, not a bug. - **Dashboard**: the Models page's provider health panel shows a provider's current state and, while open, when it will next be probed — see [Admin](#admin). - **Cold-start retry**: when a provider's synchronous warm-fill fetch fails at startup, the gateway schedules ONE background retry after a fixed 10-second delay (`warmFillRetryDelay`, not configurable). The retry uses the same `finishRefresh` bookkeeping as every other refresh, so the breaker state stays consistent with it. This recovers a transient cold-start blip, such as slow egress on the very first outbound call, not a provider that stays broken. If the retry also fails, the provider falls back to the breaker behaviour described above, unchanged. A successful retry logs one INFO line naming the provider. A failed retry logs the existing ERROR line. The retry shares its in-flight guard with the regular refresh cycle. It skips if a refresh is already running, or if one already completed since the warm-fill failure, so it never runs a second, overlapping request against the same provider. A warm fill that fails because the gateway itself is shutting down (its parent context canceled) schedules no retry at all. ## Unified vs. passthrough | | Unified (`/v1/...`) | Native passthrough (`/{provider}/...`) | |---|---|---| | Wire format | Always OpenAI-shaped in, OpenAI-shaped out. | The provider's own native format, untouched. | | Authorization | `group.allowsProviderModel` AND `group.allowsProvider`, both checked against the resolved provider (`modelRegistry.resolve` — see [Model routing](#model-routing)). | `group.allowsProvider`, always. `group.allowsProviderModel` (or, for a multi-group/personal-grant caller, `group.allowsPassthroughModelForPath`) too, but ONLY when the group's `models` list is non-empty — see the model-enforcement note below the table. `passthroughPaths`, when set, also gates the REST path, coupled to the same grant as the model check for a multi-group caller. | | Translation | `openai`-type: body forwarded verbatim (model id rewritten). `anthropic`/`gemini`: full bidirectional translation, including streaming, chunk by chunk. | None — a raw reverse proxy. | | Model alias in the response | Translated providers (`anthropic`, `gemini`) echo back the client's exact requested model string in the response's `model` field, even though the upstream call used the resolved provider model id. An `openai`-type response is a verbatim passthrough of the upstream body, so it carries whatever model id the upstream itself returned — this asymmetry is intentional, not a bug. | The upstream's own `model` field, verbatim — there is no alias to echo. | | Embeddings | `openai`-type: passthrough. `gemini`: mapped to `:embedContent`/`:batchEmbedContents`. `anthropic`: **501** — Anthropic's API has no embeddings endpoint. | Whatever the provider itself supports at that path, subject to the same `models`/`passthroughPaths` authorization as every other native passthrough request. | | Unsupported parameters | A semantically meaningful field the target provider cannot express (e.g. `n > 1`, `logit_bias`, `logprobs` on Anthropic) is a **400**, never silently dropped. | Not applicable — the request reaches the provider exactly as sent. | | Non-2xx upstream response | Wrapped in the gateway's own error envelope, with the provider's own body embedded under `error.upstream`. | Forwarded to the client exactly as the upstream sent it — status, headers, and body. | ### Native passthrough model authorization A group's `models` glob authorizes native passthrough traffic too, but only once it has something to authorize: - **`models` empty (the default)** — nothing for `allowsProviderModel` to reject, so the request body is never even read for this purpose. Every request reaches the provider exactly as it did before this feature existed. - **`models` non-empty** — the gateway reads up to 64KiB of a JSON request body looking for a top-level `model` field (a genuinely binary body — multipart, `audio/*`, `image/*`, `video/*`, `application/octet-stream` — is never read at all, since it cannot carry one). The field, when found, is checked against `models` in both its bare and `provider/model` forms. **A `model` field not found within that 64KiB window is DENIED (403)** — fail closed, not fail open. This includes a **Gemini** passthrough request: Gemini's model id lives in the URL path, never the JSON body, so a group with a restricted `models` list will see every Gemini passthrough request denied. Route Gemini traffic for a model-restricted group through the unified `/v1/*` API instead. ## Anthropic Messages API (`/v1/messages`) `POST /v1/messages` serves Anthropic SDK clients — including Claude Code — directly, in Anthropic's own Messages API request/response shape: the inbound counterpart to the outbound-only Anthropic support the unified routes already had. From model resolution through response caching, usage accounting, and adapter-error handling, this route shares one implementation with `/v1/chat/completions` (`runMeteredCall`, `routes_unified.go`) — the same group authorization (see [`GroupConfig`](#groupconfig)), [limits and accounting](#limits-and-accounting), and [caching](#caching) semantics apply. Only the wire shape and the client-facing error envelope are specific to this route. ### Authentication Anthropic SDKs authenticate with the `x-api-key` header, not `Authorization: Bearer`. This route accepts **both** — the identical credential lookup (`authStore.identify`, `auth.go`) every other route uses already reads either header, `Authorization: Bearer` taking priority if a request somehow carries both — so no separate credential path exists for this route. A failed auth answers in Anthropic's own error shape (`{"type":"error","error":{"type":"authentication_error", "message":"..."}}`), never the OpenAI-shaped `{"error":{"type":...,"code":...}}` every other route uses. ### Translation vs. passthrough Behavior depends entirely on the resolved provider's configured `type`: | Resolved provider `type` | Behavior | |---|---| | `anthropic` | **Passthrough.** The request reaches that provider's own `/v1/messages` endpoint untranslated — no message-body translation of any kind. The response reaches the client BYTE-FOR-BYTE identical, with one exception: `"model"` is rewritten to the client's own requested alias, the same routing rewrite every route applies before any adapter call — not a translation of the response body. This is a real byte-level guarantee, not an approximation: every other field's value is copied from the upstream's raw bytes without ever passing through a Go value, so a JSON integer beyond `float64`'s exact range (inside, say, a `tool_use` block's arbitrary `input`) survives intact instead of being silently rounded. | | `openai` or `gemini` | **Full bidirectional translation**, through the same `providerAdapter.chatCompletion` a `/v1/chat/completions` request against that provider already uses — its contract is OpenAI-shape in, OpenAI-shape out regardless of provider type. The Anthropic-shaped request is translated to OpenAI's shape before the call; that provider's OpenAI-shaped response is translated back to Anthropic's shape before it reaches the client. | A few translation specifics worth knowing: - **`anthropic-beta` is forwarded** to an `anthropic`-type upstream (an explicit allowlist — `Authorization`, `x-api-key`, `Cookie`, and every hop-by-hop header are never forwarded, and the gateway's own credential injection always overrides whatever the allowlist copied), so Claude Code and the SDKs can still opt into 1M context, token-efficient tools, computer use, and other beta features through this route. - **Prompt-cache tokens are billed.** Anthropic's `cache_creation_input_tokens` and `cache_read_input_tokens` fold into the same combined prompt-token count every other route's budgets track, alongside `input_tokens` — not dropped, and not priced at a separate cache-tier rate. - **`tool_result.is_error`** has no native OpenAI tool-message field. `content` — a string, Anthropic's content-block array form (Claude Code sends this), or absent — is normalized to a plain string first (OpenAI's own tool message requires one; a `null` content is rejected), then gets an `"Error: "` prefix, so an OpenAI-backed agentic loop still sees the failure signal regardless of which shape the client sent. - **Reasoning models** (OpenAI's `o1`/`o3`/`o4`/`gpt-5` families) get `max_completion_tokens` instead of `max_tokens` — the field those models reject with a 400. - **`thinking`** (Anthropic's extended-thinking config) has no OpenAI equivalent and is dropped when translating to an `openai`/`gemini` target; the gateway logs a warning naming the dropped field rather than discarding it silently. ### Streaming is not yet supported `"stream"` set to anything other than the JSON literal `false`, `null`, or an absent key gets a clean **400** `invalid_request_error` — never silently ignored, never answered non-streamed. `null` is treated the same as unset, so an SDK that serializes an unset optional field as explicit `null` never gets a false-positive rejection on an ordinary non-streaming call. This is deliberately a 400, not a 501: Anthropic's own SDKs retry any status ≥ 500, so a 501 here would turn every streaming call — the default for Claude Code — into a multi-request retry storm against the caller's own request-rate limit. The rejection happens *before* the rate limiter runs, so a streaming-rejected request never consumes budget either. A follow-up release adds the Anthropic SSE event translator (`message_start`/`content_block_delta`/`message_stop`) — a genuinely different event model from OpenAI's own stream shape this plugin already forwards on `/v1/chat/completions`. ### Route takeover On a deployment running `passthroughUnknown: true`, `POST /v1/messages` used to fall through to `next` (the router's own backing service) before this route existed — nothing in this plugin recognized the path. Now the gateway intercepts it unconditionally: `next` is never reached for this path again, regardless of `passthroughUnknown`. This is intended — the whole point of this route is for the gateway itself to answer `/v1/messages` — but it is a real behavior change for any deployment currently proxying `/v1/messages` downstream through `passthroughUnknown`. Route that traffic elsewhere, or expect the gateway to answer it directly, before upgrading. ## Image and audio endpoints `POST /v1/images/generations`, `POST /v1/audio/speech`, and `POST /v1/audio/transcriptions` follow the same pipeline as `/v1/chat/completions` — auth, model routing, group authorization, limits, adapter call — minus response caching and token/cost accounting (below). > **Upgrading from v0.1**: these three routes are now handled by the > gateway unconditionally. They are matched and served before > `passthroughUnknown` is ever consulted, so a request to any of them > **never** falls through to `next` — not even with `passthroughUnknown: > true`. If a v0.1 deployment pointed its router's backing service at one > of these exact paths and relied on passthrough to reach it, that backend > must move to a different path before upgrading. | | `openai`-type | `gemini` | `anthropic` | |---|---|---|---| | `images/generations` | Native forward. | Translated to Imagen's `:predict` (below). | **501**. | | `audio/speech` | Native forward; the binary response is streamed to the client as it arrives. | **501** — no OpenAI-compatible text-to-speech endpoint. | **501**. | | `audio/transcriptions` | Native forward; see below for how the multipart body is forwarded. | **501** — no OpenAI-compatible speech-to-text endpoint. | **501**. | - **Model routing**: `images/generations` and `audio/speech` read `model` from the JSON request body. `audio/transcriptions` reads it from the multipart request's `model` form field instead — the field can appear in any position among the request's parts, and a body with more than one `model` field is a **400**. - **Multipart forwarding**: when the client's `model` field already equals the resolved upstream model id (a bare id, the common case), the original multipart body is replayed byte-for-byte, unchanged. When it does not — a provider-prefixed id (`openai/whisper-1`) or another alias resolves to a different upstream model string — the body is rebuilt with every other part copied verbatim and only the `model` part's value rewritten, so the alias never reaches the real provider. The rebuilt body uses a new multipart boundary; the client's exact boundary is not preserved, only an equivalent body. - **Gemini image translation**: `{model, prompt, n, size, response_format}` maps to Imagen's `{instances:[{prompt}], parameters: {sampleCount, aspectRatio}}`. `size` maps to `aspectRatio`: `1024x1024`/`512x512` → `1:1`, `1792x1024` → `16:9`, `1024x1792` → `9:16`; an absent `size` defaults to `1:1`; any other value is a **400**. `response_format: "url"` is a **400** — the gateway stores nothing to host a URL from. A truthy `quality` or `style` is a **400**, the same convention chat's translators use for an unsupported field. Imagen's `predictions[].bytesBase64Encoded` maps back to OpenAI's `data[].b64_json`. - **Never cached**: image and audio responses are excluded from the response cache (`cache.enabled`) regardless of its configuration. - **Accounting**: request counters (`requestsPerMinute`/`requestsPerDay`) increment the same as any other route. Token counters never move, and cost is always recorded as 0 — images and audio are not cost-accounted. - **Body limits**: `images/generations` and `audio/speech` share a 10MiB JSON cap — no longer the unified `chat/completions`/`embeddings` routes' own cap, which is narrower (4MiB, see below); the media routes kept the original 10MiB. `audio/transcriptions` enforces the same 10MiB cap explicitly, returning **413** over it — unlike an oversized JSON body, which is silently truncated into a parse failure, a truncated multipart body can still parse a valid prefix and would forward cut-off audio upstream if it were not rejected outright. - **Retry** (above) applies to all three the same as chat/embeddings; a streaming `audio/speech` response is retried only before the first response byte reaches the client, the same zero-bytes rule as chat's streaming path. ## Admin A read-only dashboard and JSON API for operational visibility — providers, groups, model aliases, per-user/per-group/total usage against configured limits, usage charts, and a fleet-wide feed of rate-limit/budget/upstream events. No mutation of any kind; config stays owned by GitOps/Traefik as usual. Off by default (`admin.enabled: false`, or the `admin` block omitted entirely): none of the routes below are registered at all, and a request to any of them falls through to the plugin's existing 404/`passthroughUnknown` handling. The dashboard itself is a small Vue 3 + Pinia + Chart.js single-page app ([`webui/`](webui/)), built once with `vite build` and baked into the plugin as generated Go source (`admin_assets_gen.go` — see [Development](#development)); nothing under `webui/` is needed at runtime or in CI. - **Enabling it**: set `admin.enabled: true`, then flag at least one user `admin: true` (`UserConfig.admin`) so someone can actually reach `/admin/api/*`. An admin user is otherwise ordinary — their own keys, group, and limits still apply everywhere except the admin routes themselves (see "Admin traffic is never counted" below). - **`GET /admin`** serves the built app's `index.html` shell, and **`GET /admin/assets/{hashedname}`** serves its content-hashed JS/CSS. Both are deliberately served **with no authentication at all** once `admin.enabled` is true (disabled still falls through to the same 404/`passthroughUnknown` handling described above): a browser navigating straight to the URL has no way to attach a custom `Authorization` or `x-api-key` header, so gating the page itself would make it unreachable from a browser in the first place. This is safe because the shell and its assets carry **zero data of their own** — every value is fetched client-side from the JSON routes below, which stay fully gated — and the built JS/CSS is exactly what `vite build` emits from `webui/`, publicly inspectable by any admin dashboard user already. Each asset response carries a long-lived `Cache-Control: public, max-age=31536000, immutable` (the hash in the filename changes whenever the content does, so a cached response never goes stale); an unrecognized asset name is a `404` in the same error envelope every other unknown route returns. - **Browser key-entry flow**: the page reads an admin API key from the current tab's `sessionStorage` (a Pinia store owns this). Absent a stored key, or on any `401`/`403` from a JSON route, it shows an inline key-entry form instead of the dashboard. A submitted key is kept **only in `sessionStorage`** — never a cookie, `localStorage`, or any persistent store — so it disappears when the tab closes, and it is sent only as the `x-api-key` header on this page's own `/admin/api/*` fetches. - **`GET /admin/api/overview`** and **`GET /admin/api/usage`** share one gate: unauthenticated → 401, authenticated non-admin → 403, admin → serve. `overview` returns the provider list, groups, the configured model alias table (`alias`/`target` pairs, sorted by alias — see [Model aliases](#model-aliases)), redis/cache/retry status (`retry`'s `enabled`/`attempts`/`backoff` are the EFFECTIVE values after [Retry](#retry)'s own defaulting, not the raw config — `attempts` and `backoff` are both omitted when retry is disabled), the plugin version string, and three process-identity/health fields: `replica` (this process's own id — `os.Hostname()`, falling back to the `HOSTNAME` environment variable, then the literal `"unknown"`, so it is never empty; **caveat**: under Kubernetes `hostNetwork: true`, every pod on a node shares the node's own hostname, so `replica` no longer uniquely identifies one gateway instance), `instance` (the plugin instance name Traefik passed to `New`'s own `name` argument), and `warnings` (every configuration warning logged while this process was constructed — `metrics.path` colliding with another route or sitting under a reserved prefix, a `cache.ttl` of zero or negative, a bare model id served by more than one provider, and `failover.maxAttempts` × `retry.attempts` multiplying out to many outbound calls for one admitted request — always an array, never `null`, capped at 50 entries with any excess counted in the sibling `warningsDropped` field, omitted when nothing was dropped; a warning logged after construction finishes, from ordinary request handling, is never collected here, only written to the plugin's own log output). Each provider entry also carries `discoveryEnabled` (the configured `discovery` flag, so the dashboard can tell "discovery is off" apart from "discovery is on but has not refreshed yet" — both otherwise show the identical zero `lastRefresh`), `attemptsDay`/ `failuresDay`/`attemptsMinute`/`failuresMinute` (the provider's own current-window success-rate counters — see [Limits and accounting](#limits-and-accounting)), `modelRates` (the identical `attemptsDay`/`failuresDay` pair per known model, keyed by model id — no per-model minute figures, since nothing displays them; the dashboard's per-model badge falls back to a day-window-only detail instead), and `modelMeta` (the resolved context window and cost per known model — see [Model metadata](#model-metadata)), and `healthState`/`openUntil` (the discovery circuit breaker's current state — `"closed"`, `"open"`, or `"half-open"` — and, while open, when it will next attempt a half-open probe; see [Provider health](#provider-health-discovery-circuit-breaker)). `openUntil` is the zero time when `healthState` is not `"open"`. Each alias entry carries its own `modelMeta` too. `usage` returns every user's and every group's current-window counter values — `requestsPerMinute`, `requestsPerDay`, `rejectionsPerDay` (every `checkAndCount` rejection attributed to this scope today — fleet-wide via the shared store, folded into the same round trip the rejection itself already pays, so it costs no extra read; a user's, group's, and the `total` row's own `rejectionsPerDay` are all incremented together on one rejection, so the `total` row answers "how many rejections today, across everyone" directly), `tokensInPerDay`/`tokensOutPerDay`, `tokensInPerMonth`/ `tokensOutPerMonth`, `costPerDayMicroUsd`, `costPerMonthMicroUsd`, `lastSeen` (unix seconds of this scope's most recent admitted request; `0` when never seen or unknown — see [Statistics: always-on vs opt-in](#statistics-always-on-vs-opt-in) for the write side) — alongside their configured limits, plus one extra `total` row: the synthetic all-traffic scope (see [Limits and accounting]( #limits-and-accounting)), limits always `null`. A group row also carries its configured `providers`/`models`/`mcpServers`/`agents` access lists, omitted when unrestricted. `overview` also carries `features` (`{"userModelStats","latencyStats","cacheStats","lastSeen","failover"}`, every value a `bool` — which of the new statistics families this Gateway currently exposes; `GET /admin/api/config` and every `stats_read.go` endpoint's own feature-gated `404` echo the identical struct, so the dashboard gates a whole page section on one flag without guessing at a second endpoint's availability) and `pricing` (`{"override","builtin","litellm","free","unpriced"}`, every value an `int` — the live model catalog's row count by billing price source, summing to the catalog's total model count; a one-glance "how much of my fleet is actually priced" figure for the dashboard's Home page, without a second round trip to `GET /admin/api/catalog`). Polled every 5 seconds by the dashboard's shared background store (`stores/ dashboard.ts`) for as long as the app is open — a single poll behind every page, not scoped to whichever one is showing (the redesign renamed the old flat "Providers"/"Usage" tabs — see [Dashboard](#dashboard)). - **`GET /admin/api/usage/history`** returns a bucketed series for one scope/metric/window. Predates the admin dashboard redesign; kept for API stability, but the dashboard itself no longer calls it — `GET /admin/api/usage/series` (below) superseded it as the redesigned dashboard's own multi-scope time-series source. Query parameters: - `scope`: `user:{id}`, `group:{id}`, `model:{provider}/{model}`, or the literal `total`. - `metric`: `req`, `tokin`, `tokout`, or `cost`. - `window`: `hour`, `day`, or `month`. - `span` (optional): number of buckets, oldest-first, inclusive of the current (possibly partial) bucket. Defaults to the window's max — `48` for `hour`, `35` for `day`, `13` for `month` — and a larger value is a `400`. An unrecognized `scope`/`metric`/`window`, or an out-of-range `span`, is `400`; a `user`/`group` id that names no configured entity — or a `model` id outside the live catalog — is `404`; the configured store being unreachable is `503` (a chart must never read an outage as "zero usage"). Response shape: `{"scope","metric","window","points":[{"bucket":"2026082114","value":123},...]}`. - **`GET /admin/api/usage/models`** ranks models by usage — the data source for the Spend page's model breakdown and the Models page's catalog table. Query parameters: - `metric`: `req`, `tokin`, `tokout`, or `cost`. - `window`: `hour`, `day`, or `month`. - `limit` (optional): 1-100, default 20. - `span` (optional): number of buckets, ending at and including window's current (possibly partial) bucket, summed into each model's `value` — the identical span convention `usage/history` uses. Empty means `1` (the window's current bucket alone — this endpoint's original, pre-`span` behavior, unchanged for a caller that never passes it), otherwise an integer from `1` to the window's own max (`48`/`35`/`13` for `hour`/`day`/`month` — see `usage/history`'s own `span` entry above), and a value outside that range is `400`. - `prefix` (optional): narrows the catalog to model ids that start with it — applied BEFORE the store is read and before `limit` caps the result, so a search narrowed to one provider's own ids (`id`s look like `provider/model`) never pays a catalog-sized read for ids the prefix already excludes, and `limit` caps the FILTERED set's own top-N rather than silently returning fewer than `limit` matches. Up to 256 bytes; longer is `400`. Empty (the default, and what the redesigned dashboard always sends — it filters client-side instead, below) ranks the whole catalog. A prefix that matches nothing is not an error, just an empty `models` array. The Models page's own search box (`q`) and provider narrowing instead filter the already-fetched, unfiltered response client-side (`lib/model-filter.ts`, joined against `GET /admin/api/catalog` and `GET /admin/api/performance` in `ModelCatalogTable.vue`) — a harmless server round trip either way at the fleet sizes this plugin targets, and simpler than keeping a second server-side query in sync with the global scope filter. - `detail` (optional): `1` or `true` adds `requests`, `tokensIn`, `tokensOut`, `costMicroUsd` and `free` to every entry, and includes a model when ANY of those four totals is non-zero, not only the ranked `metric`. A free model with traffic therefore appears even under `metric=cost`, with `value` `0`. In detail mode ties break on `requests` descending, then `id`. `free` is `true` when a `modelMeta` entry marks the model free, or when its resolved price is known and both per-1M prices are `0`. Empty, `0` or `false` (the default) keeps the response exactly as described below; any other value is `400`. Detail mode (admin dashboard redesign) also adds `priceSource` (`billingPriceSource`'s own `"override"`/`"builtin"`/`"litellm"`/ `"free"`/`"unpriced"` classification — the same vocabulary `GET /admin/api/catalog`'s `priceSource` uses) and `r402` (how many requests for this model were refused as unpriced under a caller's cost budget over the requested span) — `r402` is ALWAYS present in detail mode, regardless of whether the response cache is configured (it has nothing to do with caching), and is read over the SAME `window` the request asked for: model-scoped `r402` counters exist at both `hour` and `day`, so `window=hour`/`window=day` read it exactly; `window=month` has no `r402` bucket of its own and converts to the exact covering `day` buckets instead (calendar-aligned, so this is exact, not an approximation — bounded by `day`'s own retention). `cacheHits` and `cacheSavedMicroUsd` are added too, but only when `features.cacheStats` (the response cache is configured) AND `window` is `day` or `month` — omitted for `window=hour` as well as when the cache is unconfigured: a model's cache counters only ever have a `day` bucket (never `hour`), so an hour-window value cannot be recovered without also counting hours outside the requested range: no exact answer exists, so none is given. `window=month` converts to the covering `day` buckets, the same way `r402` does. The dashboard's Models tab always sends `detail=1`, ranks by requests by default, and lists every column in a table under the chart, with a "free" badge instead of `$0`. - `offset` (optional): shifts the span this many whole window-steps back from now, for a period-over-period comparison read — `0` (default) is this endpoint's original, pre-`offset` behavior unchanged; the resolved value is echoed back as `offset` (an integer from `0` to the window's own max minus `span`, else `400`). Response shape: `{"metric","window","span":1,"offset":0,"models":[{"id":"uni/qwen3-next","value":4100},...]}` (`span`/`offset` echo the resolved values — always present, even when the request carried no `?span=`/`?offset=` of its own), sorted by `value` descending, ties broken on `id` ascending so a ranking stays stable between polls. **Only models with non-zero usage are returned** — a catalog runs to hundreds of models, so an empty `models` array means "nothing used in this window", never "nothing configured". Validation mirrors `usage/history`: a bad `metric`/`window`/`limit`/`span`/ `prefix`/`offset` is `400`, an unreachable store is `503`. Each `id` is the canonical `provider/model` of the provider that actually **served** the request, so a request that failed over is attributed to the provider that answered it, and one bare model id reachable through two providers ranks as two separate rows. Per-model counters are written after the response (that is the first point at which the serving model is known), which is also why a passthrough reply whose upstream reports no model id is counted for its user, group and total but for no model. The media routes (`/v1/images/generations`, `/v1/audio/speech`, `/v1/audio/transcriptions`) write the per-model request counter on a successful (2xx) response too — never tokens or cost, since media calls are not token- or cost-accounted. - **`GET /admin/api/usage/series`** (admin dashboard redesign) is the redesigned dashboard's own time-series source — one or more scopes' bucketed history for a single metric/window/span/offset, in one batched store read, replacing per-scope charting with a single request the Spend/Consumers/Models/Reliability pages all share. Query parameters: - `scope` (repeated, 1-100): `total`, `user:{id}`, `group:{id}`, `model:{canonical}`, `provider:{name}`, or `provmodel:{canonical}`. An unrecognized scope, or one naming an entity that does not exist, is skipped and listed in the response's `unknown` array rather than failing the whole request; absent entirely is `400`, more than 100 is `400`. - `metric`: which metric is valid depends on the scope kind — `user`/`group`/`total` accept `req`/`tokin`/`tokout`/`cost` (any window) plus `rej` (hour/day only); `total` additionally accepts `chit`/`cmiss`/`csave`/`r402` (hour/day only); `model` accepts `req`/`tokin`/`tokout`/`cost` (any window) plus `chit`/`csave`/`r402` (day only); `provider` accepts `attempt`/`fail`/`timeout`/`fover` (hour/day); `provmodel` accepts `attempt`/`fail` (hour/day). A metric outside its scope kind's own set is `400 "unknown metric for scope kind"`. - `window`: `hour`, `day`, or `month`. - `span`/`offset`: the same convention `usage/models` uses above. Response shape: `{"metric","window","span","offset","buckets":[...], "series":[{"scope","points":[...]}],"unknown":[...]}` — `buckets` is oldest-first bucket labels shared by every series; `unknown` is always an array, never `null`. A key-count estimate over `adminMaxKeysPerRequest` (below) is `400` before any store read; an unreachable store is `503`. - **`GET /admin/api/usage/totals`** (admin dashboard redesign) ranks entities of one kind by their totals over a span — the Home page's top-users/top-models cards, and the Spend page's group→user attribution drill-down. Query parameters: - `kind`: `user`, `group`, `usermodel`, `modeluser`, or `targetcaller`. - `window` (default `day`): `user`/`group` accept `hour`/`day`/`month`; `usermodel`/`modeluser`/`targetcaller` accept only `day`/`month`. - `span` (default `1`), `offset` (default `0`): the same convention `usage/models` uses above. - `metrics` (optional, comma list): defaults to every metric `kind` allows — `req`/`tokin`/`tokout`/`cost` for `user`/`group`/`usermodel`/ `modeluser`, `req` only for `targetcaller` (target requests carry no token/cost dimension — see [MCP and A2A](#mcp-and-a2a)); an unknown or duplicate entry is `400`. - `limit` (optional, default 200, 1-1000): rows beyond it are dropped, with `truncated: true` on the response. - `group` (optional, `kind=user` only): narrows to one group's members. - `user` (required for `kind=usermodel`), `model` (required for `kind=modeluser`), `target` (optional for `kind=targetcaller`, either `mcp/{name}` or `agent/{name}` — omitted ranks every configured target × every user). - `usermodel`/`modeluser` answer `404 "per-user-model statistics are not enabled (admin.stats.userModel)"` when that stat is off — checked only AFTER every other parameter is already known valid, so a malformed request always reports what is actually wrong with it rather than a blanket "feature disabled". A `usermodel` read narrows the model catalog down to models with ANY recent usage first (a single-metric read over covering month buckets), before ever reading a per-user counter for one — the catalog can run to hundreds of models, while a real user typically used only a handful. Response shape: `{"kind","window","span","offset","metrics":[...], "rows":[{"id","values":{"req":123,...}}],"truncated"}` — an all-zero row (across every requested metric) is dropped before ranking; rows sort by the first requested metric descending, ties by `id` ascending. `id` is a plain name for `user`/`group`, a canonical `provider/model` id for `usermodel`, a user name for `modeluser`, or `"mcp/{target}/{caller}"`/`"agent/{target}/{caller}"` for `targetcaller`. A key-count estimate over `adminMaxKeysPerRequest` is `400`; an unreachable store is `503`. - **`GET /admin/api/performance`** (admin dashboard redesign) reports provider/model availability and, when `admin.stats.latency` is on, latency percentiles — the Models page's per-row p50/p95 columns and the Reliability page's fleet-wide provider panel. Query parameters: - `kind`: `provider` or `model`. - `window`: `hour` or `day` only (a latency percentile over a month is not actionable enough to justify the extra retention this endpoint would need). - `span`/`offset`: the same convention `usage/models` uses above. - `id` (repeated, optional, up to 50): which entities to report. Omitted defaults to every configured provider (`kind=provider`) or the top 50 models by request count over the span (`kind=model`). - `series` (optional, `0` or `1`): `1` returns bucket-by-bucket figures for exactly one `id` instead of one summed row per `id` — `400` when `series=1` and `id` does not name exactly one entity. `rows` is always present in the response, never `null` — one row per `id` in the default (`series=0`) mode, or an empty array in `series=1` mode (whose own figures land in `buckets`/`points` instead). `attempts`/`failures` (and, for `kind=provider`, `timeouts`/ `failovers`) are always present; `count` is also always present (`0` means no recorded sample, whether because `admin.stats.latency` is off or the entity simply has none in the span — its JSON field carries no `omitempty`). `p50Ms`/`p95Ms`/`p99Ms`/`ttfbP50Ms`/`ttfbP95Ms` are the ones present only when `admin.stats.latency` is on AND that entity has at least one recorded sample in the span (a `nil` percentile is "no data", never a fabricated `0`) — computed server-side by linear interpolation within a latency histogram bucket (the same 13 bounds `latencyBucketBounds`, [Metrics](#metrics), uses, 0.1s to 600s, plus one overflow bucket); an observation that landed in the overflow bucket reports `overflow: true` with its percentile clamped to `600000` (the largest bound in milliseconds — a true value past that point is unknown by construction). `latencyEnabled` on the response echoes whether `admin.stats.latency` is on, so the dashboard can render "latency not enabled" once rather than per row. A key-count estimate over `adminMaxKeysPerRequest` is `400`; an unreachable store is `503`. - **`GET /admin/api/catalog`** (admin dashboard redesign, on-demand — the Models and Reliability pages' own 5-minute-stale data source, not polled with `overview`) returns every configured provider's full model catalog: id, context window, per-1M-token prices, `priceSource` (`billingPriceSource`'s own `"override"`/`"builtin"`/`"litellm"`/ `"free"`/`"unpriced"` classification — the actual billing resolution a real request gets, distinct from the display metadata `overview`'s `modelMeta` already carries: a model can have a discovery-captured context window with no configured price at all), `displayFree` (a `":free"`-suffixed model id with no configured price — billing unchanged, display-only), and its configured aliases — plus the same alias table `overview` carries. Needs no counterStore round trip at all: every field is either static configuration or the registry's own discovery state, read from the identical snapshot `overview` reads. - **`GET /admin/api/consumers`** (admin dashboard redesign, on-demand) returns every active user and every configured group for the Consumers page's directory and access-matrix views. A user row's `source` is `"inline"` or `"file"` (which config block actually defined this user — see [Users file format](#users-file-format)); `providers`/`models` are that user's own personal grant, when configured (see [Model routing](#model-routing)); `admin` is that user's own `admin: true` flag. A group row carries its configured access lists (`providers`/ `models`/`mcpServers`/`agents`/`passthroughPaths`) AS CONFIGURED, plus two RESOLVED views against the live catalog: `allowedProviders` (every currently catalogued provider name this group can reach at all, never `null`) and `modelAccess` (per provider, `{"allowed","total"}` model counts — computed via the identical `allowsProvider`/ `allowsProviderModel` check a real request's own authorization uses, so this view can never disagree with what the proxy actually enforces). `usersFile` names the configured users-file PATH only — its contents are never read or exposed here (see [Users file format]( #users-file-format)). - **`GET /admin/api/config`** (admin dashboard redesign, on-demand) is the Config page's own read-only source: the live configuration, redacted, plus the same `warnings` array `overview` carries and the same `features` flags every stats endpoint echoes. `config` is `g.cfg` round-tripped through JSON and walked recursively — every `apiKey`/ `password` JSON key redacted (an `"env:NAME"`/`"file:/path"` indirection is kept verbatim, since it names WHERE the secret is, not the secret itself; any literal value is replaced with `"[redacted]"`), every `baseUrl`/`url` JSON key passed through the same userinfo/query-string stripping `overview`'s own provider `baseUrl` already applies. A redaction failure (not expected on any real running Gateway) degrades to an empty `config` map with a warning appended, never a `500`. The Config page renders this as read-only YAML (`lib/yaml-emit.ts`) — see [Dashboard](#dashboard) below for the change-helper that turns an intended edit into a paste-ready config snippet; this endpoint itself has no write counterpart of any kind. - **`GET /admin/api/events`** (F3, v0.3 dashboard task) returns this deployment's most recent operational events — rate-limit/budget rejections and upstream/timeout/unpriced/capacity signals — for the dashboard's "Events" tab. Same gate as every route above; never calls `checkAndCount` either. Query parameter: `limit` (optional), empty means `50`, otherwise an integer from `1` to `200` inclusive, else `400`. Response shape: `{"events":[{"time","replica","instance","user","group","model","provider","route","kind","message","status"},...],"source","replica","capacity":200,"degraded"}`, newest first, `events` always an array, never `null`. `instance`/`user`/`group`/`model`/`provider`/`status` are each omitted when not applicable to the event's own kind. `kind` is one of `rate_limit`/`budget` (a `checkAndCount` rejection that also increments `usage`'s own `rejectionsPerDay` for the violated scope and for `total`, in the same round trip), `store_down` (a rejection because the configured store itself was unreachable — never counted toward `rejectionsPerDay`, since there was nothing to increment it in), `upstream`, `timeout`, `unpriced` (a model with no configured price, cost recorded as `0`), or `capacity` (the body-admission semaphore refusing a request before it is even attributed to a user — this is the one event kind recorded with no `user`/`group` at all). `route` is one of `chat/completions`, `embeddings`, `messages`, `images`, `audio/speech`, `audio/transcriptions`, `passthrough`, `mcp`, `a2a`, `mcp-federated`, `pricing`, or `capacity`. Events are kept in two places: an in-process ring buffer (`capacity` entries, oldest overwritten first — every event always lands here, regardless of Redis) and, when Redis is configured and currently reachable, best-effort mirrored onto a shared, capped Redis list (key `llmgw:events`, one `LPUSH` of the new entry plus an `LTRIM` to `capacity - 1` entries in the same pipelined round trip — no separate expiry; the list is bounded by size alone) so a dashboard reading through **any** replica in a multi-instance deployment sees every replica's own events, not just the one it happened to poll. The mirror is throttled to 50 pushes/second/replica — a rejection or failure storm must never turn it into an unbounded write amplifier against the shared store — the local ring is never throttled. `source` is `"redis"` when this read was actually answered from that shared list, or `"replica"` when it fell back to this replica's own local ring instead (Redis not configured at all, currently latched down, or this particular read failed); `degraded` is `true` only for the latter two cases — a plain "Redis was never configured" read is ordinary operation, not degradation, and stays `false`. `replica` at the response's top level is always this answering replica's own id (`overview.replica`, above), regardless of which replica's events are actually being shown. - **`GET /admin/api/targets`** returns every configured MCP server and agent for the dashboard's "MCP & Agents" tab: `{"mcpServers":[...],"agents":[...]}`, each entry `{"name","url","access","counters","health"}`. `url` has any userinfo/query string stripped, same as a provider's `baseUrl` in `overview`. `access` is the list of group names actually allowed to reach that target, computed via the identical glob match `mcpServers`/`agents` authorization itself uses (`GroupConfig.mcpServers`/`agents`), so this view can never disagree with what the proxy enforces; `null` when every configured group can reach it, `[]` when no group can. `counters` is `requestsPerMinute`/`requestsPerDay`/`requestsPerMonth` — requests only, no tokens or cost (see [MCP and A2A](#mcp-and-a2a) for why). `health` is `{"state","lastCheck","lastError","consecutiveFailures","latencyMs","source"}` — see [MCP and A2A](#mcp-and-a2a)'s "Target health" section for the full contract. `state` is `"unknown"`, `"healthy"`, or `"unhealthy"`, and `consecutiveFailures` is always present. Every other field is omitted only while this target has never once been observed; `state` alone does not decide the omission, since `"unknown"` also covers a target that has been observed but has not yet succeeded, and that case keeps every field. This route always reads existing state; when `targetHealth.enabled`, it additionally triggers a background probe sweep when one is due, so the response itself never waits on that sweep. Polled every 5 seconds, in the same batch as `overview` and `usage`. - **What's exposed**: provider names, types, base URLs (with any userinfo/query string stripped before it's ever echoed), model counts, discovery status, group/user names, membership, limits, MCP/agent target names/URLs/access, live usage counters, and — via `GET /admin/api/config` — the redacted config tree itself. **Never exposed**: API keys (not even digests), provider keys, the Redis password, or users-file path contents — every secret-bearing field is redacted from every response (see `GET /admin/api/config`'s own redaction rules above). - **Admin traffic is never counted**: none of the twelve `/admin/api/*` JSON routes call `checkAndCount` — admin polling never moves any user's or group's `requestsPerMinute`/`requestsPerDay` counters, and usage statistics reflect real LLM traffic only (operator directive: an admin dashboard tab left open, however aggressively it polls, must never itself distort the numbers it displays). The direct consequence: an admin's own `requestsPerMinute`/`requestsPerDay` limit, if configured, is never enforced against admin-route traffic either — an admin key holder can poll any of the twelve routes as fast as they like. This is an accepted trade-off, not an oversight: these are admin-gated, cheap reads, and an admin holder polling aggressively is a self-inflicted, not a shared, resource cost. `GET /admin` and `GET /admin/assets/*` count nothing either, for the simpler reason that they are unauthenticated — there is no identified user to count a request against. - **Response headers**: all twelve JSON routes set `X-Content-Type-Options: nosniff` and `Cache-Control: no-store`; every hashed asset sets `X-Content-Type-Options: nosniff` and its own immutable `Cache-Control` (above). Every one of these routes — `GET /admin`, every hashed asset, and the twelve JSON routes — shares one `Content-Security-Policy` header (`default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'`) — no `'unsafe-inline'`, since the built app has no inline script or style of any kind, only external `/admin/assets/*` references; `img-src` additionally allows `data:` for the page's inlined favicon. A browser only enforces CSP against the top-level document that names it, so an asset response's own header governs nothing when the browser fetched it as a `