Files
traefik-llmgateway/llmgateway.go
T
lukaszraczylo 65b2f121c4 feat(admin): add fleet statistics and admin read endpoints
Counters (always on when admin is enabled):
- last seen per user and group, written asynchronously after
  admission, throttled to one write per scope per minute per replica
- hourly provider and model attempts, failures and timeouts
- failovers per provider, rejections per hour, and unpriced (402)
  refusals
- cache hits, misses and cost saved
- request counts per target and caller

Opt-in counters under admin.stats (both default off):
- userModel: usage per user and model
- latency: latency histograms per provider and model

All counters ride the existing Redis batches. A test measures three
round trips per request with defaults and with every statistic on. A
cache hit takes two. A model request is no longer counted for failed
failover candidates.

New admin endpoints:
- usage/series and usage/totals
- performance, with percentiles from the histograms
- catalog, with the price billing actually uses and its source
- consumers, with key source, grants and model access
- config, with secrets redacted

The overview gains features and a pricing summary. Usage gains
lastSeen. usage/models gains offset and more detail fields. Reads are
chunked at 1600 keys per round trip and capped at 64000 keys per
request.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
2026-09-23 16:40:45 +01:00

1493 lines
74 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package traefikllmgateway implements a Traefik Yaegi middleware plugin: a
// multi-provider LLM gateway with groups, per-user API keys, request/token/
// cost limits, and an MCP/A2A registry and proxy.
package traefikllmgateway
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"runtime"
"strings"
"sync"
"time"
telemetry "github.com/lukaszraczylo/oss-telemetry"
)
// Config is the plugin's dynamic configuration, populated by Traefik from
// the middleware's YAML/testData. Every limit or list field follows the
// convention: zero/empty/omitted means unlimited/all.
// Field order below is fieldalignment-verified (golangci-lint's govet
// enable-all, run with -fix against a scratch copy to derive the exact
// zero-waste sequence, then hand-applied here so every field keeps its
// original doc comment) — see providerState's own doc comment (registry.go)
// for the general pointer-first/scalar-last convention this follows.
type Config struct {
Redis *RedisConfig `json:"redis,omitempty"`
// Admin gates the read-only admin dashboard (spec §4, v0.2): nil or
// Admin.Enabled false means the /admin* routes are not registered at
// all — ServeHTTP falls through to its existing 404/passthroughUnknown
// handling for those paths, preserving v0.1 behavior exactly.
Admin *AdminConfig `json:"admin,omitempty"`
// Metrics gates the Prometheus text-exposition endpoint (default
// path /metrics, configurable — see MetricsConfig, metrics.go). nil
// or Metrics.Enabled false means the route is not registered at
// all, the identical nil-disables convention Admin above already
// uses.
Metrics *MetricsConfig `json:"metrics,omitempty"`
Pricing map[string]*ModelPricing `json:"pricing,omitempty"`
MCPServers map[string]*TargetConfig `json:"mcpServers,omitempty"`
Agents map[string]*AgentConfig `json:"agents,omitempty"`
Users *UsersConfig `json:"users,omitempty"`
Groups map[string]*GroupConfig `json:"groups,omitempty"`
// ModelAliases maps an operator-defined alias id to a target model id
// (spec §5, v0.2) — e.g. {"aliased/coding": "anthropic/claude-sonnet-4-5"}.
// An exact alias match wins resolution before any other rule
// (modelRegistry.resolve, registry.go); the target then resolves
// through the normal rules. nil/omitted preserves v0.1 behavior
// exactly: resolve never consults an empty alias map, so no existing
// model id's resolution changes. Validated at construction
// (validateModelAliases, registry.go).
ModelAliases map[string]string `json:"modelAliases,omitempty"`
Providers map[string]*ProviderConfig `json:"providers,omitempty"`
// ModelMeta declares per-model metadata overrides (feature v0.23):
// context window size and per-token cost, keyed by an exact
// "provider/model" id or a bare model id (applying wherever that bare
// id resolves — modelmeta.go's lookupModelMetaConfig checks the exact
// form first). This ALWAYS wins over discovery-captured context or
// the built-in LiteLLM-synced table (resolveModelMeta, modelmeta.go)
// — distinct from Pricing above, which drives request cost
// ACCOUNTING (costMicros); ModelMeta drives metadata EXPOSURE
// (GET /v1/models' context_window/pricing extension fields, the
// admin dashboard). The one exception (verify-core fix, round 4):
// an entry marked Free is ALSO honoured by billing itself
// (unifiedCostMicros, via modelMetaFree) — cost is 0 and known,
// never a real price the model's bare id happens to share with the
// built-in table, so the 402 cost-budget guard (priceKnown, which
// already treated Free the same way) and actual billing can never
// disagree about the same model. Every other ModelMeta field still
// only drives exposure, never billing. nil/omitted preserves prior
// behavior exactly: every model's metadata falls through to
// discovery/builtin/absent, same as before this feature existed.
// Validated at construction (validateModelMeta, modelmeta.go).
ModelMeta map[string]*ModelMetaConfig `json:"modelMeta,omitempty"`
// Breaker configures the discovery circuit breaker (feat/provider-
// health): how many consecutive discovery-refresh failures open a
// provider's breaker, and how long it then backs off before probing
// again. A struct value, not a pointer, for the same reason as Retry/
// Cache below — its fields are individually zero-means-default
// (validateBreakerConfig, registry.go), not gated by one Enabled
// flag, so "omitempty" on this tag would be a no-op that misleadingly
// implies otherwise. The zero value (Config{} with no breaker block
// at all) resolves to every documented default and behaves exactly
// like a deployment with no circuit breaker at all for a provider
// that never fails — see validateBreakerConfig's own doc comment.
// Failover configures feat/failover — cross-provider failover for a
// bare model id more than one configured provider serves. See
// FailoverConfig's own doc comment (failover.go) for the full
// contract; a struct value, not a pointer, for the same
// zero-means-default reason as Breaker/Retry/Cache below.
// RequestTimeout is the progress-based upstream request timeout
// (feature: request timeout) applied to every provider adapter call
// and to the MCP/A2A target proxy, unless a provider sets its own
// ProviderConfig.RequestTimeout override. A Go duration string (e.g.
// "5m", "90s"), parsed and validated by resolveRequestTimeout
// (timeout.go). Empty (the default) resolves to defaultRequestTimeout,
// five minutes — a deliberate behavior change for every deployment,
// not an invisible bug fix: before this field existed, an upstream
// provider that accepted a connection and then hung could hang the
// gateway request forever (see defaultRequestTimeout's own doc
// comment, and README.md's "Request timeout" section). A non-empty
// value that fails to parse, or parses to a duration <= 0, is a
// construction error — zero or negative never means "no timeout" here,
// since that would silently reintroduce the exact hang this feature
// fixes.
RequestTimeout string `json:"requestTimeout,omitempty"`
Failover FailoverConfig `json:"failover"`
Breaker BreakerConfig `json:"breaker"`
// Retry is a struct value, not a pointer, because its own Enabled
// field is the on/off signal (unlike Redis/Users, where the block's
// mere presence is the signal) — so its tag omits "omitempty":
// encoding/json never treats a struct value as "empty" regardless of
// its fields, so "omitempty" here would be a no-op that misleadingly
// implies otherwise.
Retry RetryConfig `json:"retry"`
// Cache is a struct value, not a pointer, for the same reason as Retry
// above: its own Enabled field is the on/off signal.
Cache CacheConfig `json:"cache"`
// TargetHealth configures feat/target-health's opt-in active probe
// sweep for MCP servers and A2A agents — see TargetHealthConfig's own
// doc comment. A struct value, not a pointer, for the same
// zero-means-default reason as Breaker above: passive recording and
// exposure (GET /admin/api/targets, llmgateway_target_healthy) stay
// on unconditionally regardless of this block's presence.
TargetHealth TargetHealthConfig `json:"targetHealth"`
// MaxInFlightBodyRequests caps how many unified and media JSON/
// multipart requests (routes_unified.go's runUnified via
// readAndDecodeUnifiedBody; routes_media.go's decodeMediaJSONRequest/
// readAdmittedCapped, used by handleImagesGenerations/
// handleAudioSpeech/handleAudioTranscriptions) may concurrently be
// INSIDE their own read+decode step at once (security review finding
// 1b, 2026-08-22; scope narrowed to read+decode only in round 3 — see
// acquireBodyAdmission's own doc comment, routes_unified.go, for why
// this is deliberately NOT a cap on total in-flight requests). 0 (the
// default) self-tunes from GOMAXPROCS (defaultBodyAdmissionCap) rather
// than adding an operator knob for a value the runtime can already
// infer (house rule: prefer self-tuning over operator knobs where the
// right value can be inferred; add a flag only when it genuinely
// cannot be). A positive value pins an exact cap instead and always
// wins over the auto-tuned default (house rule: an explicit override
// always wins) — e.g. a deployment with a known, tighter memory
// budget than GOMAXPROCS alone would infer — but is still clamped to
// maxExplicitBodyAdmissionCap (llmgateway.go): "wins" means it
// overrides the self-tuned value, not that an operator can silently
// disable the bound entirely with an unbounded number.
MaxInFlightBodyRequests int `json:"maxInFlightBodyRequests,omitempty"`
PassthroughUnknown bool `json:"passthroughUnknown,omitempty"`
// AllowUnpricedWithCostBudget restores the pre-fix behavior for a
// request whose served model has NO configured price while the calling
// user or group DOES configure costPerDayUSD/costPerMonthUSD.
//
// Security audit run-1, finding F-1 (high): an unpriced model costs 0
// in accounting, a zero cost writes no cost counter (limiter.account,
// limits.go), and checkAndCount reads exactly that never-written
// counter — so the money budget can never accrue and can never fire.
// The gateway now REFUSES such a request (runMeteredCall,
// routes_unified.go) rather than serving it free against a control the
// operator explicitly configured.
//
// Set true to serve it anyway, accepting that the cost budget does not
// bound that model. This changes nothing for a deployment that
// configures no cost budget at all — the refusal is already scoped to
// callers that set one — and nothing for a model that has a price.
// Prefer adding the missing "pricing" entry over setting this.
AllowUnpricedWithCostBudget bool `json:"allowUnpricedWithCostBudget,omitempty"`
}
// ProviderConfig describes one upstream LLM provider.
type ProviderConfig struct {
// Passthrough gates whether this provider's native passthrough route
// ("/{providerName}/{rest...}", routes_passthrough.go's
// handlePassthrough) is reachable at all. nil or true (the default)
// preserves prior behavior exactly: every configured provider stays
// reachable through native passthrough unless an operator explicitly
// opts it out — FAIL-OPEN BY DESIGN, the same default direction as
// every allow-list field in this package (GroupConfig's Providers/
// Models/MCPServers/Agents/PassthroughPaths: empty/unset permits, an
// explicit restriction narrows). Set false to disable a provider's
// native passthrough entirely — e.g. an operator who wants clients
// reaching a provider only through the unified /v1/* routes, never
// its raw native API. A disabled provider's passthrough prefix is
// treated exactly like an unconfigured one: ServeHTTP falls through
// to the ordinary unknown-route 404, without even reaching auth
// (security+performance audit, 2026-08-22). This does not affect the
// MCP/A2A target proxy (mcp_a2a.go), which has its own routing
// prefix and its own allow-list (GroupConfig.MCPServers/Agents) —
// operators who route native tools like macstudio-rerank/parakeet-
// mlx/piper-tts through THIS field's route must leave it unset or
// true to keep that traffic flowing.
Passthrough *bool `json:"passthrough,omitempty"`
Type string `json:"type"`
BaseURL string `json:"baseUrl,omitempty"`
APIKey string `json:"apiKey"`
DiscoveryInterval string `json:"discoveryInterval,omitempty"`
// RequestTimeout overrides the global Config.RequestTimeout for this
// provider alone — an explicit per-provider override always wins over
// the global default (house rule). Same Go-duration-string form,
// same validation (resolveRequestTimeout, timeout.go): empty inherits
// the global (or its own default of five minutes when the global is
// also unset); a non-empty value that fails to parse, or parses to a
// duration <= 0, is a construction error naming this provider.
RequestTimeout string `json:"requestTimeout,omitempty"`
// MetadataPath is an optional second discovery endpoint (feature
// v0.23), fetched alongside the provider's normal listModels call
// when set: a path such as "/api/v0/models" (LM Studio's own,
// non-OpenAI-compatible models endpoint) that reports per-model
// context length. Only openai-type adapters read this field
// (provider_openai.go's openaiAdapter.fetchModelMetadata) — an
// anthropic/gemini provider configuring it has no effect. Empty (the
// default) captures no metadata, preserving prior behavior exactly.
// A failed or absent fetch is always non-fatal to discovery itself:
// no metadata is captured, nothing else changes.
MetadataPath string `json:"metadataPath,omitempty"`
Models []string `json:"models,omitempty"`
Discovery bool `json:"discovery,omitempty"`
}
// GroupConfig describes a group's access and limits. Empty/omitted
// Providers, Models, MCPServers, Agents mean all are allowed.
type GroupConfig struct {
Limits *LimitsConfig `json:"limits,omitempty"`
Providers []string `json:"providers,omitempty"`
Models []string `json:"models,omitempty"`
// Cache overrides the global cache.enabled setting for this group's
// requests (spec §2): nil inherits the global setting, false opts the
// group out even when caching is globally enabled, and true opts the
// group in — but only when the global cache block is actually
// configured (newAuthStore rejects true otherwise, a constructor
// error, since there is nothing to inherit ttl/maxBodyBytes from).
Cache *bool `json:"cache,omitempty"`
// CacheTTL overrides the global cache.ttl (a Go duration string, e.g.
// "5m") for this group's cache entries only. Empty (the default)
// inherits the global TTL. Set means: parsed via time.ParseDuration at
// construction (newAuthStore, auth.go); a malformed duration, a
// duration <= 0, or CacheTTL set while the global cache block is not
// configured (cfg.Cache.Enabled false — there is no global TTL to
// override) is a constructor error naming the group. CacheTTL is
// independent of Cache above: it names the TTL to use WHEN this
// group's requests are cached, whether that caching comes from the
// global default or from this group's own Cache:true.
CacheTTL string `json:"cacheTTL,omitempty"`
MCPServers []string `json:"mcpServers,omitempty"`
Agents []string `json:"agents,omitempty"`
// PassthroughPaths restricts which REST path this group's native
// passthrough requests may address, glob-matched (matchesGlob,
// auth.go, via Go's path.Match) against passthroughRoute's own
// "rest" — the ESCAPED path segment (r.URL.EscapedPath()-derived,
// never percent-decoded) AFTER the provider name (e.g. "v1/files" for
// a request to "/openai/v1/files"). Empty/omitted (the default) means
// every path is allowed, preserving prior behavior exactly and
// matching every other allow-list field's own empty-means-all
// semantics in this struct — DO NOT invert that convention here. Set
// a non-empty list to restrict a group to specific native endpoints
// (e.g. ["v1/chat/completions"]), without narrowing any group that
// leaves it unset (security review, 2026-08-22). The "home" group's
// own providers/models/mcpServers/agents lists are already empty
// (unrestricted); leaving this unset too keeps it that way.
//
// FOOTGUN (path.Match's documented behavior, verified in review): "*"
// matches within ONE path segment only — it does NOT cross "/". A
// pattern of exactly ["*"] therefore does NOT mean "allow every
// path": it denies every "rest" with more than one segment, which is
// most real passthrough traffic (e.g. "v1/chat/completions", three
// segments, never matches "*"). Leave PassthroughPaths unset/empty to
// actually allow everything; if you need a genuine multi-segment
// wildcard, list every segment explicitly (e.g. "v1/*") or repeat a
// "*/*/*"-shaped pattern per depth — there is no "**" (path.Match has
// no such construct).
PassthroughPaths []string `json:"passthroughPaths,omitempty"`
}
// LimitsConfig holds request/token/cost limits. Zero means unlimited.
type LimitsConfig struct {
RequestsPerMinute int64 `json:"requestsPerMinute,omitempty"`
RequestsPerDay int64 `json:"requestsPerDay,omitempty"`
TokensPerDay int64 `json:"tokensPerDay,omitempty"`
TokensPerMonth int64 `json:"tokensPerMonth,omitempty"`
CostPerDayUSD float64 `json:"costPerDayUSD,omitempty"`
CostPerMonthUSD float64 `json:"costPerMonthUSD,omitempty"`
}
// UsersConfig combines inline users with an optional file-backed source.
type UsersConfig struct {
File string `json:"file,omitempty"`
Inline []*UserConfig `json:"inline,omitempty"`
}
// UserConfig describes one API-key holder.
type UserConfig struct {
Limits *LimitsConfig `json:"limits,omitempty"`
Name string `json:"name"`
Group string `json:"group"`
// Groups lists further groups this user belongs to, alongside Group
// (multi-group support). Effective membership is Group first (when
// non-empty), then Groups, de-duplicated, order preserved
// (effectiveGroupNames, auth.go) — at least one group, from either
// field, is required: a user naming none at all is a constructor
// error. Every named group, whether from Group or Groups, must
// reference a configured group — an unknown one is a constructor
// error naming the user and the unknown group, the same
// "llmgateway: user %q references unknown group %q" shape Group
// alone always used.
//
// A multi-group user's effective authorization is the UNION of every
// member group's own grant (effectiveGroup, auth.go): a request is
// allowed when it matches some SINGLE member group's providers AND
// models — providers from one member group never combine with models
// only some OTHER member group allows to authorize a request neither
// alone would (no cross-grant leak; see Providers/Models below, which
// carry the identical rule for the personal grant). Each member
// group's own limits apply as ITS OWN limit scope (kind "group", id
// that group's own name) — never merged into one combined limit.
// mcpServers/agents are unioned across every member group, with the
// rule that any ONE member group's empty (allow-all) list makes the
// union allow-all too. passthroughPaths is NOT unioned this way — a
// native passthrough request is authorized only when ONE grant (a
// member group's own, or the personal grant, which has none of its
// own and so borrows the PRIMARY group's — Group, or Groups[0] when
// Group is empty) covers the provider AND the path AND (when
// restricted) the model TOGETHER; unioning it the same way as the
// other lists would let joining a further, less-restricted group
// silently strip a more specific member group's own path restriction
// on a provider that other group does not even reach. The effective
// cache setting is false if any member group sets cache false, else
// true if any sets true, else nil (inherit); cacheTTL is the smallest
// positive cacheTTL configured among member groups.
//
// A user belonging to exactly one group (Group alone, Groups empty)
// with no personal grant below behaves byte-identically to before
// this field existed, down to *group pointer identity
// (effectiveGroup's own back-compat rule, auth.go).
Groups []string `json:"groups,omitempty"`
APIKey string `json:"apiKey"`
// Providers and Models together describe this user's own PERSONAL
// grant — access beyond whatever their member group(s) already allow,
// with the identical glob semantics a group's own Providers/Models
// carry (matchesGlob, auth.go: an empty list matches anything). The
// personal grant exists at all ONLY when Providers is non-empty:
// Models alone, with Providers left empty, is a constructor error
// naming the user (`personal "models" requires personal "providers"`)
// — an empty Providers list means "any provider" (matchesGlob's own
// empty-means-all contract), so a Models-only grant would silently
// reach every configured provider, including ones no member group of
// this user covers at all. Providers alone (Models left empty) is
// valid and means "every model on those providers." Leaving both
// empty/omitted (the default) means this user has no personal grant
// beyond their member group(s)'. Like a member group's own grant, the
// personal grant participates in the "some SINGLE grant must allow
// provider AND model" rule Groups' own doc comment above states — and,
// for native passthrough specifically, the identical rule extended to
// a provider+path+model triple, borrowing the PRIMARY group's own
// passthroughPaths (Groups' own doc comment above): it has no limits
// of its own, and contributes to a request's authorization only
// through those combined checks, never as a separate limit scope.
Providers []string `json:"providers,omitempty"`
Models []string `json:"models,omitempty"`
// Admin grants this user access to the read-only admin dashboard
// (spec §4, v0.2), whether the user is inline or file-sourced —
// file-users granting admin is operator-controlled via the Secret
// backing Users.File, which is an accepted trust boundary. An admin
// user is otherwise an ordinary user: their own keys, group, and
// limits still apply, including to the admin routes themselves.
Admin bool `json:"admin,omitempty"`
}
// RedisConfig configures the distributed limit-state backend.
type RedisConfig struct {
FailOpen *bool `json:"failOpen,omitempty"`
Address string `json:"address"`
Password string `json:"password,omitempty"`
DB int `json:"db,omitempty"`
// PoolSize overrides respClient's self-tuned connection pool size
// (defaultRespPoolSize, resp.go: GOMAXPROCS clamped to [4, 8]) — perf
// finding 1, 2026-08-2x audit. Left at 0 (the default), the pool
// self-tunes; an operator who sets this explicitly always wins over
// that auto-tuning (house engineering rule), e.g. to open more
// connections than a low-GOMAXPROCS container would otherwise pick
// for a Redis known to have plenty of headroom, or fewer against a
// connection-constrained managed Redis tier. A negative value, or one
// above respPoolConfigMax (resp.go), is a construction error
// (buildRedisClient, below) — negative can never mean "no pool", and
// an unbounded value would eagerly allocate that many *respConn
// structs at construction before a single request arrives.
PoolSize int `json:"poolSize,omitempty"`
// TLS dials Redis over TLS instead of plaintext TCP, verifying the
// server's certificate against the host in Address.
//
// Security audit run-1: before this field existed the hand-rolled
// RESP client could only speak plaintext, so the AUTH password, the
// per-principal counter keys and any cached response bodies were
// unprotectable in transit by any configuration — TLS was not merely
// unconfigured, it was impossible. Defaults to false, so an existing
// deployment is unchanged; enable it when the gateway reaches Redis
// over anything the operator does not already encrypt (a service mesh
// with mTLS, an stunnel/TLS sidecar, or a private link all satisfy
// the same invariant externally, in which case leaving this off is
// fine).
TLS bool `json:"tls,omitempty"`
}
// RetryConfig configures same-provider retry on transient upstream
// failures (connection errors, HTTP 429, HTTP 5xx) — spec §1. The zero
// value (Enabled: false) disables retry, preserving v0.1 behavior
// exactly: every request still makes exactly one upstream attempt.
// Attempts and Backoff are validated (and defaulted, when left zero) by
// newRetryPolicy (retry.go) only when Enabled is true.
type RetryConfig struct {
// Backoff is the base wait before the first retry (a Go duration
// string, e.g. "250ms"); it doubles on each further retry, capped at
// 2s per wait. Defaults to "250ms" when Enabled and left empty.
Backoff string `json:"backoff,omitempty"`
// Attempts is the number of retries performed AFTER the first try —
// not the total try count. 1 (the default, applied when Enabled and
// left at 0) allows one retry: two tries total. The maximum, 3,
// allows three retries: four tries total. A value outside 1..3 is a
// construction error.
Attempts int `json:"attempts,omitempty"`
Enabled bool `json:"enabled,omitempty"`
}
// CacheConfig configures the opt-in, Redis-backed response cache for
// unified non-streaming chat/embeddings responses (spec §2). The zero
// value (Enabled: false) disables caching, preserving v0.1 behavior
// exactly. TTL and MaxBodyBytes are validated (and defaulted, when left
// zero) by validateCacheConfig (cache.go) only when Enabled is true.
// Caching additionally requires config.Redis (buildResponseCache disables
// it with a one-time warning, not a constructor error, when Enabled is
// true but Redis is not configured) — a per-replica cache without a
// shared backend would serve divergent responses across replicas.
type CacheConfig struct {
// TTL is how long a cached response stays valid (a Go duration
// string, e.g. "5m"). Defaults to defaultCacheTTL when Enabled and
// left empty.
TTL string `json:"ttl,omitempty"`
// MaxBodyBytes caps how large a response body may be to still be
// stored; a larger one is skipped (spec §2's "body ≤ maxBodyBytes").
// Defaults to defaultCacheMaxBodyBytes when Enabled and left at 0; a
// value above maxCacheMaxBodyBytes is a construction error.
MaxBodyBytes int `json:"maxBodyBytes,omitempty"`
Enabled bool `json:"enabled,omitempty"`
}
// BreakerConfig configures the per-provider discovery circuit breaker
// (feat/provider-health): closed -> open -> half-open, driven entirely by
// discovery-refresh outcomes (registry.go's providerState.finishRefresh),
// never by request-path traffic. Every field is individually zero-means-
// default (validateBreakerConfig, registry.go) — there is no Enabled
// flag, unlike Retry/Cache above, because the breaker is always live:
// its defaults are chosen so a provider that never fails never notices
// it exists, so there is nothing meaningful to "disable". Field order
// (both strings before the int) is fieldalignment-sensitive, the same
// convention providerState's own doc comment explains.
type BreakerConfig struct {
// OpenDuration is the base backoff a newly opened breaker waits
// before its first half-open probe (a Go duration string, e.g.
// "1m"). Doubles on every further failed probe, capped at
// MaxOpenDuration. Empty (the default) uses
// defaultBreakerOpenDuration (registry.go).
OpenDuration string `json:"openDuration,omitempty"`
// MaxOpenDuration caps the exponential backoff OpenDuration above
// doubles into. Empty (the default) uses
// defaultBreakerMaxOpenDuration (registry.go). Must be >=
// OpenDuration when both are set.
MaxOpenDuration string `json:"maxOpenDuration,omitempty"`
// FailureThreshold is how many CONSECUTIVE failed discovery refreshes
// open the breaker. 0 (the default) uses defaultBreakerFailureThreshold
// (registry.go). A value outside 1..maxBreakerFailureThreshold is a
// construction error.
FailureThreshold int `json:"failureThreshold,omitempty"`
}
// TargetHealthConfig configures feat/target-health's OPT-IN active probe
// sweep for MCP servers and A2A agents (target_health.go). Passive
// recording from real request traffic, and exposure via GET
// /admin/api/targets and the llmgateway_target_healthy gauge, are ALWAYS
// on regardless of this block's presence — Enabled here gates ONLY the
// background probe sweep (maybeSweepTargetHealth, target_health.go). The
// zero value (Enabled: false) preserves prior behavior exactly: a
// deployment that predates this feature, or one that never configures
// this block, sends no active probe traffic to any MCP server or agent.
// Field order (string before the two scalars) is fieldalignment-
// sensitive, the same convention BreakerConfig's own doc comment above
// explains.
type TargetHealthConfig struct {
// ProbeInterval is the minimum time between sweeps (a Go duration
// string, e.g. "60s"). Empty (the default) uses
// defaultTargetHealthProbeInterval (target_health.go). Must be
// between minTargetHealthProbeInterval (10s) and
// maxTargetHealthProbeInterval (1h) when set, or construction fails.
ProbeInterval string `json:"probeInterval,omitempty"`
// FailureThreshold is how many CONSECUTIVE failed observations
// (probe or traffic) mark a target unhealthy — see
// targetHealthTracker's own doc comment (target_health.go) for the
// exact state rule. 0 (the default) uses
// defaultTargetHealthFailureThreshold (3). Must be between 1 and 100,
// or construction fails.
FailureThreshold int `json:"failureThreshold,omitempty"`
// Enabled opts into the active probe sweep. false (the default)
// means no MCP server or A2A agent is ever proactively contacted for
// health purposes — passive recording from real proxied traffic
// still runs either way.
Enabled bool `json:"enabled,omitempty"`
}
// AdminConfig configures the read-only admin dashboard (spec §4, v0.2).
// The zero value (Enabled: false) disables it, preserving v0.1 behavior
// exactly: no /admin* route is registered, so those paths fall through
// to ServeHTTP's existing 404/passthroughUnknown handling like any other
// unrecognized path.
//
// Stats (admin-redesign WP-A, DECISIONS Q4) is nil for every config that
// predates it, or explicitly omitted — additive and default-preserving
// (house rule): a nil Stats behaves exactly like &AdminStatsConfig{} would
// (every sub-flag off), never an error, so an upgrade with no config
// change adds no new counter writes. newConfiguredLimiter (below) reads
// this alongside Enabled to derive the limiter's own statsAdmin/
// statsUserModel/statsLatency fields.
type AdminConfig struct {
Stats *AdminStatsConfig `json:"stats,omitempty"`
Enabled bool `json:"enabled,omitempty"`
}
// AdminStatsConfig opts into two counter families this task adds that are
// NOT part of the "always-on-with-admin" set (DECISIONS): both default to
// false (off) even when Admin.Enabled is true, since each has its own,
// separate cost/cardinality trade-off an operator should choose
// explicitly rather than inherit silently from turning the dashboard on.
type AdminStatsConfig struct {
// UserModel opts into the per-(user, model) usage breakdown
// (kindUserModel counters, limits.go) — cardinality grows with
// users x models actually used, unlike every always-on family.
UserModel bool `json:"userModel,omitempty"`
// Latency opts into the per-bucket upstream latency counters
// (accountWith's own latency block, limits.go) — up to 8 more
// counterIncr entries per request (4 duration + 4 TTFB buckets) riding
// the existing account batch, only ever written when a candidate's
// watchdogBody actually reports a latencySample.
Latency bool `json:"latency,omitempty"`
}
// ModelPricing overrides the built-in per-model price table.
type ModelPricing struct {
InputPerM float64 `json:"inputPerM"`
OutputPerM float64 `json:"outputPerM"`
}
// ModelMetaConfig overrides one model's context window and per-token
// cost (feature v0.23, Config.ModelMeta) — layered ahead of discovery-
// captured context and the built-in LiteLLM-synced table
// (resolveModelMeta, modelmeta.go). ContextTokens left at 0 falls
// through to the next layer rather than overriding with an explicit
// unknown; the same holds for InputCostPerMTokMicroUSD/
// OutputCostPerMTokMicroUSD when Free is false. Free, when true,
// explicitly zeroes both cost fields regardless of whatever they are
// set to — a KNOWN zero cost, distinct from the "not overridden here"
// fallback a zero cost field means when Free is false. Setting Free
// true together with a non-zero cost field is rejected at construction
// (validateModelMeta) as a contradictory config.
type ModelMetaConfig struct {
ContextTokens int `json:"contextTokens,omitempty"`
InputCostPerMTokMicroUSD int64 `json:"inputCostPerMTokMicroUsd,omitempty"`
OutputCostPerMTokMicroUSD int64 `json:"outputCostPerMTokMicroUsd,omitempty"`
Free bool `json:"free,omitempty"`
}
// TargetConfig is a proxied upstream target (MCP server).
type TargetConfig struct {
URL string `json:"url"`
}
// AgentConfig is a proxied A2A agent.
type AgentConfig struct {
URL string `json:"url"`
Card string `json:"card,omitempty"`
}
// CreateConfig returns a Config populated with the plugin's defaults.
func CreateConfig() *Config {
return &Config{}
}
// minBodyAdmissionCap/maxBodyAdmissionCap clamp defaultBodyAdmissionCap's
// self-tuned result (security review finding 1b; numbers revised round
// 3, 2026-08-22 — see this const block's own history below for why).
// The floor keeps a single-vCPU sidecar from self-tuning down to a cap so
// small it throttles ordinary traffic, and the ceiling keeps a very
// large host from self-tuning up to a cap so high the semaphore stops
// bounding anything.
//
// REVISED (round 3, coordinator ruling — fixes an availability
// regression the round-2 numbers shipped alongside): round 2 sized these
// against a "whole request" hold duration (acquireBodyAdmission was held
// for the entire handler, including the upstream round trip) and a
// ~170MB-per-slot estimate that bundled the decode, the re-marshal, AND
// the response buffer together. Measured, zero-config, at round 2's
// numbers: 400 concurrent chat requests against a 2-vCPU pod (cap 16)
// saw 220 succeed and 180 get 503 — an LLM completion is I/O-bound for
// seconds to minutes, so a cap sized for a brief decode became a hard
// ceiling on TOTAL in-flight requests instead.
//
// acquireBodyAdmission's slot is now held ONLY for the read+decode step
// itself (see its own doc comment, routes_unified.go) — typically
// single-digit milliseconds for a body under maxUnifiedRequestBytes
// (4MiB), a reduction of roughly three to four orders of magnitude in
// HOLD DURATION versus round 2. The relevant per-slot memory figure is
// also now decode-only, not decode+response: measured at up to ~51.5MB
// LIVE heap (12.3x) for an adversarial 4MiB body shaped to maximize
// per-value allocation overhead, with ~189MB TotalAlloc (~45x) of mostly
// short-lived garbage collected within the same decode
// (maxUnifiedRequestBytes' own doc comment, routes_unified.go, has the
// full measurement). At the new ceiling (256), a SUSTAINED worst case —
// every slot simultaneously decoding the adversarial shape, which the
// now-brief hold duration makes far less likely to persist than round
// 2's multi-second window — peaks around 256 × 51.5MB ≈ 13.2GB LIVE
// heap; the cumulative TotalAlloc figure (256 × 189MB ≈ 48.4GB) is
// garbage-collection churn, not simultaneously resident memory, and is
// not the number to size a pod's memory limit against. Both the floor
// and ceiling were doubled from round 2 (16→32, 128→256) and the
// per-CPU multiplier tripled (8→24): a raise smaller than the hold-
// duration reduction warrants, deliberately conservative, since the
// worst-case LIVE-heap arithmetic above still needs to stay defensible
// on a modest node.
const (
minBodyAdmissionCap = 8
maxBodyAdmissionCap = 64
)
// NOTE on these bounds (round-4 correction, measured): review round 3
// shipped [32, 256], reasoning from the isolated json.Unmarshal peak
// (~51.5MB for an adversarial 4MiB body). The verifier then measured the
// END-TO-END per-slot cost through the real handler — ~80-108MB, since
// the raw body []byte stays live alongside the decoded map and GC lags —
// and swept the cap against peak HeapInuse under 400 concurrent
// adversarial requests:
//
// cap 16 -> 1.5GB cap 64 -> 6.1GB
// cap 32 -> 3.4GB cap 256 -> 21.1GB
//
// A 256 ceiling therefore does not prevent the OOM this semaphore exists
// to prevent: it is above any realistic pod budget. It is still strictly
// better than the unbounded base (25GB on the same burst), but "better
// than unbounded" was never the goal.
//
// So the bounds are cut to [8, 64]: 64 keeps a large-memory host from
// being throttled below what it can actually serve, 8 sets a floor for
// the self-tuned VALUE on a degenerate host (see the correction below for
// why that floor is not actually the binding constraint on a real one).
// This costs legitimate throughput almost nothing because the slot is now
// held ONLY across read+decode (round-3 fix) — microseconds to low
// milliseconds — not across the upstream call; an operator whose host
// genuinely wants more sets Config.MaxInFlightBodyRequests explicitly,
// which always wins. Deriving the ceiling from the process's real memory
// budget (runtime/debug.SetMemoryLimit(-1), which the chart already sets
// via goMemLimitPercentage) is the better long-term shape and is left as
// a follow-up: it needs its own Yaegi-interpreted verification, since
// runtime/debug's availability under the interpreter is unproven here.
//
// CORRECTION (review-auth finding F3, 2026-09 review — the paragraph
// above, and the reference-deployment claim it used to carry, both
// overstated what the 8-slot floor actually protects): defaultBodyAdmissionCap
// computes n = GOMAXPROCS(0) * bodyAdmissionCapPerCPU (24, below) and only
// clamps DOWN to minBodyAdmissionCap when n itself is smaller than 8.
// Since GOMAXPROCS is always >= 1 on any real process, n is always >= 24
// — the 8-slot floor can therefore never actually bind on a real host; it
// only guards a degenerate GOMAXPROCS<1 read defaultBodyAdmissionCap has
// no other reason to expect. The reference deployment's real self-tuned
// cap, at 1 vCPU, is 24, not 8: linearly interpolating the measured table
// above between its 16 and 32 points (not separately measured) puts a
// 24-slot worst case around ~2.4GB peak HeapInuse — five times the
// resources.limits.memory: 512Mi the earlier text claimed this floor kept
// the pod within. Preserving today's actual numeric behavior (house rule:
// additive and default-preserving — lowering bodyAdmissionCapPerCPU or
// minBodyAdmissionCap here would be a real throughput regression for
// every existing deployment, not a comment fix), this correction changes
// no constant: an operator who needs the semaphore to actually hold a
// small pod within 512Mi under this worst case must set
// Config.MaxInFlightBodyRequests explicitly (for example 4-6), which
// always wins over the self-tuned value regardless of GOMAXPROCS.
// bodyAdmissionCapPerCPU is defaultBodyAdmissionCap's GOMAXPROCS
// multiplier (revised round 3 — see minBodyAdmissionCap's own doc
// comment for the full before/after reasoning): a decode-scoped slot is
// held for milliseconds, not the seconds-to-minutes an I/O-bound LLM
// completion takes, so — unlike round 2's comment, which reasoned about
// upstream-latency headroom for a hold spanning the whole request — this
// multiplier is sized against CPU throughput for a brief, CPU-bound
// json.Unmarshal burst, not I/O parking.
const bodyAdmissionCapPerCPU = 24
// defaultBodyAdmissionCap returns the self-tuned in-flight cap
// acquireBodyAdmission's semaphore is sized to when Config.
// MaxInFlightBodyRequests is left at 0 (security review finding 1b,
// 2026-08-22): GOMAXPROCS-derived rather than a single constant that would
// be wrong for both a 1-vCPU sidecar and a 32-vCPU ingress node alike
// (house rule: prefer self-tuning over operator knobs where the right
// value can be inferred from the environment), clamped to
// [minBodyAdmissionCap, maxBodyAdmissionCap].
func defaultBodyAdmissionCap() int {
n := runtime.GOMAXPROCS(0) * bodyAdmissionCapPerCPU
if n < minBodyAdmissionCap {
return minBodyAdmissionCap
}
if n > maxBodyAdmissionCap {
return maxBodyAdmissionCap
}
return n
}
// maxExplicitBodyAdmissionCap clamps an explicit Config.
// MaxInFlightBodyRequests override (round 3, 2026-08-22 review — the
// round-2 version accepted any positive value verbatim, including
// something like math.MaxInt from a fat-fingered config, which would
// silently disable the bound entirely by making the semaphore
// effectively unbounded). The house rule ("explicit override always
// wins") governs the choice between self-tuning and pinning a value, not
// whether a pinned value gets any sanity check at all — an operator who
// genuinely needs more concurrency than this still gets a very high
// ceiling, just not an unbounded one reachable by typo.
const maxExplicitBodyAdmissionCap = 10_000
// Gateway is the Traefik middleware handler.
//
// Field order below is fieldalignment-verified (golangci-lint's govet
// enable-all, run with -fix against a scratch copy to derive the exact
// zero-waste sequence, then hand-applied here so every field keeps its
// original doc comment) — see providerState's own doc comment
// (registry.go) for the general convention.
// Field order below is fieldalignment-derived (govet, enabled via this
// repo's global golangci-lint config), not declaration-convenience
// order: pointer-shaped fields (interfaces, pointers, maps, chans,
// strings, slices) are grouped first, then plain scalars (logGate/
// failoverConfig/time.Duration) last, so the GC's pointer-scan span
// stays as short as possible. See adminProviderView's identical
// convention (admin.go) for the same reasoning spelled out once; this
// struct is the other place it applies.
//
// fieldalignment's 192-vs-184 pointer-bytes hint on this struct is a
// DELIBERATE, pre-existing exception, not an oversight — see
// targetHealthLastSweepUnixNano's own doc comment immediately below.
// That field must stay first so sync/atomic's 64-bit operations are
// correctly aligned on armv7 and 386, which Traefik ships builds for,
// and sync/atomic guarantees that alignment only for the first word of
// an allocated struct. Total struct size is unchanged either way (264
// bytes, verified), so the exception costs no padding; moving
// targetHealthSweeping up to close the gap was tried and made the
// report worse (200 vs 184). Correctness on 32-bit platforms wins over
// the linter hint, so the check is suppressed here rather than the
// field order being "fixed".
//
//nolint:govet
type Gateway struct {
next http.Handler
events *eventLog
bodyAdmission chan struct{}
failoverHealth *requestHealthTracker
targetHealth *targetHealthTracker
limiter *limiter
registry *modelRegistry
adapters map[string]providerAdapter
cfg *Config
auth *authStore
cache *responseCache
redisClient *respClient
latency *latencyStore
targetClient *http.Client
provenance *provenanceStore
name string
replica string
failoverLogGate logGate
metricsNets []*net.IPNet
configWarnings []string
targetHealthCfg targetHealthConfig
failover failoverConfig
targetTimeout time.Duration
targetHealthLastSweepUnixNano int64
configWarningsDropped int
warnMu sync.Mutex
targetHealthSweeping int32
collectingWarnings bool
}
// replicaID resolves this process's identity for Gateway.replica (F4/F9,
// v0.3 dashboard task): os.Hostname() when available — the pod name under
// Kubernetes with the common hostNetwork: false setup — else the
// HOSTNAME environment variable some container runtimes set directly,
// else the literal "unknown", so the field is never empty. Called once,
// in newGateway, not per request.
//
// CAVEAT (document in README, WP-C): under hostNetwork: true, every pod
// on a node shares the node's own hostname, so replica no longer
// uniquely identifies one gateway instance.
func replicaID() string {
if h, err := os.Hostname(); err == nil && h != "" {
return h
}
if h := os.Getenv("HOSTNAME"); h != "" {
return h
}
return "unknown"
}
// telemetryStartupOnce keeps the anonymous "plugin loaded" ping to one per
// process. Traefik calls New once per ROUTE that uses this plugin, and a
// busy ingress can have many; oss-telemetry does not deduplicate on the
// client side (the server does), so the gate belongs here.
var telemetryStartupOnce sync.Once
// sendStartupTelemetry fires at most one anonymous "plugin loaded" ping
// per process, matching traefikoidc's behaviour.
//
// What leaves the pod: project name, version and a timestamp. No
// identifiers, no config, no provider names, no keys, no request data.
// The call never blocks (it runs in oss-telemetry's own goroutine), never
// panics, never retries and never returns an error, so it cannot affect
// request handling or delay Traefik's startup.
//
// Silent unless the build was stamped: an unstamped tree still carries
// devPluginVersion, so developers and operators building from a checkout
// never phone home. Operators of a RELEASE build opt out with any of
// DO_NOT_TRACK=1, OSS_TELEMETRY_DISABLED=1, or
// TRAEFIK_LLMGATEWAY_DISABLE_TELEMETRY=1 (oss-telemetry reads all three;
// the last is this project's name uppercased with dashes replaced). This
// is documented in README.md — a plugin that phones home without saying
// so in its own README would be indefensible.
func sendStartupTelemetry() {
telemetryStartupOnce.Do(func() {
if !shouldSendTelemetry(pluginVersion) {
return
}
telemetry.Send(telemetryProjectName, pluginVersion)
})
}
// telemetryProjectName is the project identifier reported in the ping. It
// matches the repository name, and doubles as the prefix oss-telemetry
// derives its per-project opt-out variable from
// (TRAEFIK_LLMGATEWAY_DISABLE_TELEMETRY).
const telemetryProjectName = "traefik-llmgateway"
// shouldSendTelemetry reports whether a build stamped with version may
// emit a startup ping. Only release-stamped builds may: an empty or
// dev-sentinel version means nothing stamped this tree, so it is a
// checkout, a test run or a local build, none of which should phone home.
//
// Split out from sendStartupTelemetry so the decision is testable without
// a network: the send itself goes through oss-telemetry, whose endpoint
// is deliberately not overridable from consuming code.
func shouldSendTelemetry(version string) bool {
return version != "" && version != devPluginVersion
}
// New creates the middleware. NOTE: no tail call — Yaegi zeroes
// multi-value tail-call returns across the reflect boundary.
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
g, err := newGateway(ctx, next, config, name)
if err != nil {
return nil, err
}
// Deliberately AFTER construction succeeds: a config the gateway
// rejects never loaded a working plugin, so counting it as an install
// would inflate the numbers with failures.
sendStartupTelemetry()
return g, nil
}
// Close releases resources this Gateway instance holds open: currently,
// the pooled Redis connections behind redisClient (perf finding 1),
// shared between the limiter's redisStore and the response cache — a
// nil-safe no-op when Redis is not configured.
//
// Review fix (Should-Fix 4, 2026-08-2x): nothing in this codebase calls
// Close automatically. Traefik's plugin contract for a locally-loaded
// middleware is exactly `func New(...) (http.Handler, error)`, above —
// the returned value satisfies only http.Handler, with no Shutdown/Close
// interface Traefik itself checks for or invokes on a discarded
// instance. README.md's "Config hot-reload" section already documents
// this repository has no evidence of Traefik's teardown mechanism for an
// old instance at config-reload swap time; a plugin-side Close, however
// correct, has nowhere in Traefik's own contract to be wired to. Close
// exists for direct Go callers — tests, or a future framework/harness
// that DOES hold such a hook — to release pooled connections
// deterministically instead of depending solely on GC finalizing the
// underlying net.Conns. A discarded, never-Closed Gateway still leaks at
// most respPoolMax (8) idle connections rather than the pre-pool single
// connection — a real but bounded regression the pool accepts (perf
// finding 1), because nothing in Traefik's own contract gives this
// plugin a way to do better.
func (g *Gateway) Close() error {
if g.redisClient == nil {
return nil
}
return g.redisClient.Close()
}
func newGateway(ctx context.Context, next http.Handler, config *Config, name string) (*Gateway, error) {
if config == nil || len(config.Providers) == 0 {
return nil, errors.New("llmgateway: at least one provider must be configured")
}
if err := validateTargetURLs(config); err != nil {
return nil, err
}
// Finding F4, 2026-09 review: validate every Config.Pricing override
// at construction — non-nil, finite, >= 0 — rather than letting a
// negative/NaN/Inf entry reach costMicrosKnownFor (pricing.go), where it
// would still pass the "is this model priced" check yet silently
// disable that model's costPerDayUSD/costPerMonthUSD budget forever.
// See validatePricing's own doc comment for the full reasoning; the
// returned map is intentionally discarded — validatePricing returns
// raw unchanged on success, so there is nothing to assign back.
if _, err := validatePricing(config.Pricing); err != nil {
return nil, err
}
auth, err := newAuthStore(config)
if err != nil {
return nil, err
}
g := &Gateway{next: next, name: name, cfg: config, auth: auth}
// replica/collectingWarnings (F4/F9/F10, v0.3 dashboard task): resolved
// immediately, before anything below can call warnf — every warnf
// call from here to this function's return is a construction-time
// warning noteConfigWarning (logger.go) must collect.
g.replica = replicaID()
g.collectingWarnings = true
// feat/failover: validated once, here — see FailoverConfig's own doc
// comment (failover.go) for the default (disabled, coordinator
// ruling) and what "single-provider deployment behaves exactly as
// before" means in practice.
failoverCfg, err := validateFailoverConfig(config.Failover)
if err != nil {
return nil, err
}
g.failover = failoverCfg
g.failoverHealth = newRequestHealthTracker()
// Security audit run-1: nothing validates the PRODUCT of the two
// retry axes, which compose multiplicatively at runtime. Surface it
// once at construction rather than leaving an operator to discover it
// during an upstream incident — the moment the amplification actually
// fires, since retryPolicy.do only retries on 429/5xx.
if failoverCfg.enabled && config.Retry.Enabled {
retries := config.Retry.Attempts
if retries == 0 {
retries = defaultRetryAttempts
}
if worst := failoverCfg.maxAttempts * (retries + 1); worst > warnComposedOutboundCalls {
// Q5 (coordinator decision, v0.3 dashboard task): promoted
// logf -> warnf — this is a config warning (an operator may
// want to know about it, resolved deterministically, no
// request affected), the exact class warnf's own doc comment
// describes, and GET /admin/api/overview's Warnings field
// (F10) should surface it.
g.warnf("config: failover.maxAttempts (%d) and retry.attempts (%d) compose to as many as %d outbound provider calls for ONE admitted client request; each consumes the provider quota shared by every tenant of this gateway",
failoverCfg.maxAttempts, retries, worst)
}
}
// feat/target-health: validated once, here — passive recording and
// exposure stay always-on regardless of this block; Enabled gates
// only maybeSweepTargetHealth's own active probe sweep
// (target_health.go).
targetHealthCfg, err := validateTargetHealthConfig(config.TargetHealth)
if err != nil {
return nil, err
}
g.targetHealthCfg = targetHealthCfg
g.targetHealth = newTargetHealthTracker(targetHealthCfg.failureThreshold)
// feat: instrument upstream latency — always constructed, regardless
// of whether Config.Metrics is nil/disabled; see g.latency's own doc
// comment above for why an always-present, usually-empty store is the
// right default rather than a conditional nil.
g.latency = newLatencyStore()
// feat: expose token-accounting provenance — always constructed, here,
// regardless of whether Config.Metrics is nil/disabled, mirroring
// g.latency's own identical "always-present" ruling immediately above.
g.provenance = newProvenanceStore()
// A malformed Config.Metrics.AllowedCIDRs entry is a constructor
// error (validate-at-construction), not a silently-ignored allowlist
// entry an operator would only discover was never applied by testing
// it — parseMetricsCIDRs' own doc comment (metrics.go).
metricsNets, err := parseMetricsCIDRs(config.Metrics)
if err != nil {
return nil, err
}
g.metricsNets = metricsNets
// review-auth finding F9, 2026-09 review: metrics.path was never
// validated at all. A value with no leading "/" can never match
// ServeHTTP's exact r.URL.Path comparison for the metrics route
// (metrics.go's metricsPath/handleMetrics), silently making the
// endpoint unreachable — kept as a hard construction error, below.
//
// verify-core fix (round 4): a value inside one of the gateway's
// other fixed route namespaces (reservedRoutePrefix, below) used to
// be a hard construction error too, but that broke real, previously
// WORKING configs on upgrade: ServeHTTP matches the exact metrics
// path (a GET, handleMetrics) before it ever reaches the /v1, /mcp,
// /a2a or admin dispatch branches — see ServeHTTP's own routing
// order — so "/v1/metrics" (and any other path merely sitting under
// one of those prefixes) already routed correctly and only ever
// shadows THAT one exact path, never the reverse. Refusing to build
// the middleware for a config that worked yesterday violates the
// house rule that an upgrade with no config change preserves prior
// behavior. This now only warns, so an operator who genuinely wants
// "/v1/metrics" keeps it working, while still being told it shadows
// the real /v1/... route at that one path.
if config.Metrics != nil && config.Metrics.Enabled && config.Metrics.Path != "" {
if !strings.HasPrefix(config.Metrics.Path, "/") {
return nil, fmt.Errorf("llmgateway: metrics: path must start with \"/\", got %q", config.Metrics.Path)
}
// ServeHTTP matches GET /v1/models and the admin GET paths BEFORE
// the metrics route, so a metrics.path equal to one of those is
// unreachable; any other path under a reserved prefix is reached
// first by metrics and shadows that one route instead.
switch {
case config.Metrics.Path == "/v1/models" || (adminEnabled(config) && isAdminPath(config.Metrics.Path)):
g.warnf("config: metrics.path %q is matched earlier by another GET route; the metrics endpoint is unreachable at this path", config.Metrics.Path)
case reservedRoutePrefix(config.Metrics.Path):
g.warnf("config: metrics.path %q sits at or under a reserved route prefix (%s); metrics is served at this exact path instead of the route normally there — confirm it is intentional", config.Metrics.Path, strings.Join(reservedRoutePrefixes, ", "))
}
}
// bodyAdmissionCap: an explicit MaxInFlightBodyRequests always wins
// over the self-tuned default (house rule: explicit override always
// wins) — see Config.MaxInFlightBodyRequests' own doc comment. 0 (the
// field's zero value, and every config that predates this field) means
// "self-tune", never "cap at zero" — a channel with a zero buffer
// would reject every request outright, which is never the intent of
// an operator who simply never set this field. An explicit override
// is still clamped to maxExplicitBodyAdmissionCap (round 3, 2026-08-22
// review) — "wins" means it overrides the self-tuned VALUE, not that
// it can disable the bound entirely via an unbounded number.
bodyAdmissionCap := defaultBodyAdmissionCap()
if config.MaxInFlightBodyRequests > 0 {
bodyAdmissionCap = config.MaxInFlightBodyRequests
if bodyAdmissionCap > maxExplicitBodyAdmissionCap {
bodyAdmissionCap = maxExplicitBodyAdmissionCap
}
}
g.bodyAdmission = make(chan struct{}, bodyAdmissionCap)
if config.Users != nil && config.Users.File != "" {
if err = attachUsersFile(auth, config.Users.File, g); err != nil {
return nil, err
}
}
// The Redis client (nil when config.Redis is absent) is built once,
// here, and shared between the limiter's redisStore and the response
// cache below — spec §2's "reuse the SAME respClient instance as the
// limiter's redisStore (one connection, one config)". Building it
// separately per consumer would dial (and AUTH/SELECT) a second TCP
// connection to the exact same server for no benefit.
redisClient, err := buildRedisClient(config.Redis)
if err != nil {
return nil, err
}
g.redisClient = redisClient
lim := newConfiguredLimiter(config, redisClient)
lim.logf = g.errorf
g.limiter = lim
// events (F3, v0.3 dashboard task): built here, after redisClient/
// limiter both exist — shares the SAME respClient (spec §2, above)
// and reuses the limiter's own bounded spawn (limits.go's
// providerAttemptSpawnCap semaphore) rather than a second one, so an
// event-feed write storm and a provider-attempt write storm compete
// for the identical bounded pool instead of two independent ones.
g.events = &eventLog{
ring: &eventRing{},
client: redisClient,
replica: g.replica,
instance: g.name,
nowFn: time.Now,
spawn: lim.spawn,
logf: g.errorf,
}
cache, err := buildResponseCache(config.Cache, redisClient, g.logf, g.warnf, g.errorf)
if err != nil {
return nil, err
}
g.cache = cache
adapters, err := buildAdapters(config)
if err != nil {
return nil, err
}
g.adapters = adapters
// P5 fix, additive part (admin dashboard redesign verify round): warn
// — never fail construction — about a provider baseUrl that does not
// parse as a URL. buildAdapters (providers.go) only ever trims a
// trailing slash off BaseURL; it never validated the value itself, so
// an operator's typo (an unescaped '/' in embedded userinfo, a
// malformed '%' escape, ...) previously surfaced only indirectly, as
// sanitizeBaseURL's own placeholder (admin.go) wherever the dashboard
// echoed it back. A warning, not a construction error, keeps this
// additive and default-preserving: a deployment that has run for a
// long time with an unparseable baseUrl keeps running unchanged, now
// with an explicit signal in GET /admin/api/overview's Warnings field
// instead of a silently redacted dashboard value. Iteration order over
// config.Providers is unspecified (Go map order), which is fine here:
// every warning is independent and their relative order is never
// observed by anything but a human reading the list.
for name, pc := range config.Providers {
if pc == nil || pc.BaseURL == "" {
continue
}
// The parse error itself is NEVER interpolated into this message:
// Go's *url.Error.Error() embeds the raw input string verbatim
// ("parse \"<raw>\": <reason>"), which is exactly the credential-
// bearing value sanitizeBaseURL exists to withhold — this warning
// would otherwise reopen the same leak through g.configWarnings /
// GET /admin/api/overview's own Warnings field instead of through
// baseUrl directly.
if _, parseErr := url.Parse(pc.BaseURL); parseErr != nil {
g.warnf("config: provider %q baseUrl does not parse as a URL; the admin dashboard will show it redacted as %q", name, unparseableBaseURLPlaceholder)
}
}
// targetTimeout: the global default only (resolveRequestTimeout,
// timeout.go) — an MCP/A2A target has no per-target override the way a
// provider does (ProviderConfig.RequestTimeout). Resolved again here,
// separately from buildAdapters' own identical call, rather than
// widening buildAdapters' return signature to hand the value back:
// buildAdapters(cfg *Config) (map[string]providerAdapter, error) is
// called directly by several existing tests, and parsing the same
// short duration string twice at construction time is cheap.
targetTimeout, err := resolveRequestTimeout(config.RequestTimeout, "requestTimeout")
if err != nil {
return nil, err
}
g.targetTimeout = targetTimeout
g.targetClient = newAdapterHTTPClient(targetTimeout)
registry, err := newModelRegistry(adapters, config, g.errorf)
if err != nil {
return nil, err
}
// Routes the self-resolving conditions (a model id two providers both
// serve) to WARN instead of ERROR — see the registry's warn field.
registry.warn = g.warnf
// ctx is passed through unwrapped, not re-bounded to warmFillTimeout here:
// warmFill already gives each discovery-enabled provider its own fresh
// warmFillTimeout budget per provider (registry.go). Wrapping ctx to a
// single shared warmFillTimeout here would make that per-provider budget
// a lie — provider 2..N would inherit whatever is left of the first
// provider's deadline instead of a full one. Construction's worst case is
// therefore N * warmFillTimeout (N = discovery-enabled providers), which
// is accepted.
registry.warmFill(ctx)
g.registry = registry
// F10 (v0.3 dashboard task): construction is finished — every warnf
// call from here on is a runtime warning, not a config warning, and
// noteConfigWarning must stop collecting it into g.configWarnings.
//
// Locked (item 4, this round): noteConfigWarning (logger.go) always
// reads collectingWarnings under warnMu, and this write is the one
// place that used to set it unlocked — harmless today (no goroutine
// calls warnf during construction: sweepTargetHealth/refreshProvider
// only ever start from ServeHTTP, after New has already returned),
// but taking the same lock here removes the need to keep re-proving
// that absence every time this file changes.
g.warnMu.Lock()
g.collectingWarnings = false
g.warnMu.Unlock()
return g, nil
}
// reservedRoutePrefixes are the path namespaces ServeHTTP already fixes:
// the admin dashboard (adminPagePath, admin.go), the OpenAI-compatible
// "/v1/..." routes, the MCP federation/target-proxy "/mcp/..." routes
// (mcp_federation.go/mcp_a2a.go), and the A2A target-proxy "/a2a/..."
// routes (mcp_a2a.go). reservedConfigNames (providers.go) already
// reserves "v1"/"mcp"/"a2a" as PROVIDER names for the identical
// collision reason; this is metrics.path's own counterpart of that check
// (review-auth finding F9, 2026-09 review — newGateway, above). Used only
// to WARN, not to refuse construction (verify-core fix, round 4):
// ServeHTTP matches the exact metrics path before dispatching into any of
// these namespaces, so a metrics.path sitting under one only ever shadows
// that one exact path — not a hazard worth breaking a working config over.
var reservedRoutePrefixes = []string{adminPagePath, "/v1", "/mcp", "/a2a"}
// reservedRoutePrefix reports whether path equals, or sits under, one of
// reservedRoutePrefixes.
func reservedRoutePrefix(path string) bool {
for _, prefix := range reservedRoutePrefixes {
if path == prefix || strings.HasPrefix(path, prefix+"/") {
return true
}
}
return false
}
// buildRedisClient validates rc and returns a respClient for it, or nil
// when rc is nil (Redis not configured). Split out of what was previously
// newConfiguredLimiter's own inline construction so newGateway can share
// one respClient — one dialled connection, one AUTH/SELECT handshake —
// between the limiter's redisStore and the response cache (cache.go),
// instead of each dialing its own connection to the identical server.
func buildRedisClient(rc *RedisConfig) (*respClient, error) {
if rc == nil {
return nil, nil
}
if rc.Address == "" {
return nil, errors.New("llmgateway: redis: address must not be empty")
}
if rc.DB < 0 {
return nil, fmt.Errorf("llmgateway: redis: db must not be negative, got %d", rc.DB)
}
if rc.PoolSize < 0 {
return nil, fmt.Errorf("llmgateway: redis: poolSize must not be negative, got %d", rc.PoolSize)
}
if rc.PoolSize > respPoolConfigMax {
return nil, fmt.Errorf("llmgateway: redis: poolSize must not exceed %d, got %d", respPoolConfigMax, rc.PoolSize)
}
password, err := resolveSecret(rc.Password)
if err != nil {
return nil, fmt.Errorf("llmgateway: redis: %w", err)
}
// PoolSize left at 0 keeps newRESPClient's own self-tuned default
// (defaultRespPoolSize, resp.go); an explicit positive value always
// overrides it (RedisConfig.PoolSize's own doc comment).
var client *respClient
if rc.PoolSize > 0 {
client = newRESPClientPool(rc.Address, password, rc.DB, rc.PoolSize)
} else {
client = newRESPClient(rc.Address, password, rc.DB)
}
// Set on the constructed client rather than threaded through either
// constructor: neither signature needs to change for it, and both
// have many call sites (respClient.tls's own doc comment, resp.go).
client.tls = rc.TLS
return client, nil
}
// newConfiguredLimiter builds the limiter for config, using client (nil
// means config.Redis was absent) as its redisStore backend. failOpen
// defaults to true (a Redis outage must not take the whole gateway down
// unless an operator opts into strict enforcement) unless
// RedisConfig.FailOpen is explicitly set.
//
// admin-redesign WP-A: also derives the limiter's own statsAdmin/
// statsUserModel/statsLatency fields from config.Admin, regardless of
// which store branch built l — no new constructor parameter, since
// newLimiter's own signature (store, failOpen) is a public contract this
// task does not touch; every field it does not set here (lastSeen) is
// already initialized by newLimiter itself.
func newConfiguredLimiter(config *Config, client *respClient) *limiter {
var l *limiter
if client == nil {
l = newLimiter(nil, true)
} else {
failOpen := true
if config.Redis.FailOpen != nil {
failOpen = *config.Redis.FailOpen
}
l = newLimiter(newRedisStore(client), failOpen)
}
l.statsAdmin = adminEnabled(config)
if l.statsAdmin && config.Admin.Stats != nil {
l.statsUserModel = config.Admin.Stats.UserModel
l.statsLatency = config.Admin.Stats.Latency
}
return l
}
// attachUsersFile performs the synchronous initial load of a Users.File path
// and wires auth up for throttled hot reload. A failing initial load is a
// constructor error — fail fast on bad config rather than start with an
// empty file-sourced user set.
func attachUsersFile(auth *authStore, path string, log gatewayLogger) error {
// Stat before load, matching maybeReload's ordering: capturing the mtime
// before reading content means a write landing between the stat and the
// read still shows up as a further mtime change on the first post-
// startup reload check, instead of being silently lost forever — a
// stat-after-load can observe a newer mtime for content it never read.
//
// statErr is intentionally ignored here: if the stat fails, lastModTime
// stays at its zero value, so the first maybeReload call sees any real
// mtime as "changed" and reloads once more — a harmless extra reload of
// content already loaded, not a correctness problem.
info, statErr := os.Stat(path)
uf := newUsersFile(path)
initial, err := uf.load()
if err != nil {
return fmt.Errorf("llmgateway: cannot load initial users file %q: %w", path, err)
}
if err := auth.replaceFileUsers(initial); err != nil {
return fmt.Errorf("llmgateway: initial users file %q: %w", path, err)
}
auth.usersFile = uf
auth.log = log
if statErr == nil {
auth.lastModTime = info.ModTime()
}
auth.lastCheck = auth.nowFn()
return nil
}
// ServeHTTP is the internal router. It grows in later tasks.
func (g *Gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
sw := &statusTrackingWriter{ResponseWriter: w}
defer recoverPanic(sw, g)
// pruneRejectionsAfterReload only runs when maybeReload just performed
// a REAL reload (users_file.go's own doc comment explains why it must
// never run on every request) — a deleted file-sourced user must not
// keep exporting llmgateway_rate_limit_rejections_total forever
// (metrics.go).
if g.auth.maybeReload() {
g.pruneRejectionsAfterReload()
}
g.registry.maybeRefresh(r.Context())
if r.Method == http.MethodGet && r.URL.Path == "/v1/models" {
g.handleModels(sw, r)
return
}
if r.Method == http.MethodPost && (r.URL.Path == "/v1/chat/completions" || r.URL.Path == "/v1/embeddings") {
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)
if !ok {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
if r.URL.Path == "/v1/chat/completions" {
g.handleChat(sw, r, u, grp)
} else {
g.handleEmbeddings(sw, r, u, grp)
}
return
}
if r.Method == http.MethodPost && r.URL.Path == messagesPath {
// Anthropic SDKs authenticate with x-api-key, not Authorization:
// Bearer — g.auth.identify (auth.go) already reads both headers,
// Bearer taking priority when a caller somehow sends both, so no
// route-specific auth handling is needed here beyond using the
// same identify/logAuthEvent call every other route already
// makes. A failed auth gets the Anthropic error shape
// (writeAnthropicError, routes_messages.go), not writeOAIError's
// — the one thing that differs from every route above.
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)
if !ok {
writeAnthropicError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
g.handleMessages(sw, r, u, grp)
return
}
if r.Method == http.MethodPost && (r.URL.Path == imagesGenerationsPath || r.URL.Path == audioSpeechPath || r.URL.Path == audioTranscriptionsPath) {
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)
if !ok {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
switch r.URL.Path {
case imagesGenerationsPath:
g.handleImagesGenerations(sw, r, u, grp)
case audioSpeechPath:
g.handleAudioSpeech(sw, r, u, grp)
case audioTranscriptionsPath:
g.handleAudioTranscriptions(sw, r, u, grp)
}
return
}
// Admin routes (spec §4, v0.2) are matched only when adminEnabled: a
// nil or disabled Config.Admin means none of these paths are
// registered at all, so a request to /admin* falls through to the
// same 404/passthroughUnknown handling as any other unrecognized
// path — no special-casing needed for the disabled case.
if adminEnabled(g.cfg) && r.Method == http.MethodGet && isAdminPath(r.URL.Path) {
g.handleAdmin(sw, r)
return
}
// Metrics route (metrics.go): matched only when metricsEnabled — a
// nil or disabled Config.Metrics means the configured path is not
// registered at all, mirroring the admin route's own gate above.
if metricsEnabled(g.cfg) && r.Method == http.MethodGet && r.URL.Path == metricsPath(g.cfg) {
g.handleMetrics(sw, r)
return
}
if r.Method == http.MethodGet && (r.URL.Path == "/v1/mcp/servers" || r.URL.Path == "/v1/agents") {
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)
if !ok {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
if r.URL.Path == "/v1/mcp/servers" {
g.handleMCPServers(sw, grp)
} else {
g.handleAgents(sw, grp)
}
return
}
// Federated /mcp (Feature C, v0.21) is matched ahead of the per-server
// "/mcp/{name}/..." target proxy below: targetRoute's own splitting
// already rejects a bare "/mcp" (no name segment to route to), so
// there is no ambiguity between the two, but this ordering keeps every
// exact-path route grouped together (mirroring "/v1/mcp/servers" and
// "/v1/agents" immediately above) ahead of the prefix-matched one.
//
// Checked on r.URL.Path alone, BEFORE the method check: a GET or
// DELETE to exactly "/mcp" is a real, well-known route addressed with
// the wrong verb (SF8, review round 2, 2026-08-21) — 405 with an
// Allow header naming the one method this route accepts, matching
// RFC 9110 §15.5.6, rather than falling through to passthroughUnknown/
// 404 the way a path this handler has never heard of would. No
// auth check runs first: which HTTP method a route accepts is
// unauthenticated request-shape information, not something worth
// gating behind a valid API key.
if r.URL.Path == federatedMCPPath {
if r.Method != http.MethodPost {
sw.Header().Set("Allow", http.MethodPost)
writeOAIError(sw, http.StatusMethodNotAllowed, "invalid_request_error", "method not allowed")
return
}
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)
if !ok {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
g.handleMCPFederated(sw, r, u, grp)
return
}
if name, rest, ok := targetRoute(r.URL.EscapedPath(), targetKindMCP); ok {
u, grp, authOK := g.auth.identify(r)
g.logAuthEvent(authOK, authEventUserName(u), r)
if !authOK {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
g.handleTargetProxy(sw, r, u, grp, targetKindMCP, name, rest)
return
}
if name, rest, ok := targetRoute(r.URL.EscapedPath(), targetKindAgent); ok {
u, grp, authOK := g.auth.identify(r)
g.logAuthEvent(authOK, authEventUserName(u), r)
if !authOK {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
g.handleTargetProxy(sw, r, u, grp, targetKindAgent, name, rest)
return
}
if providerName, rest, ok := passthroughRoute(r.URL.EscapedPath()); ok {
// providerPassthroughEnabled (providers.go, security+performance
// audit 2026-08-22): a provider with Passthrough explicitly set
// false is treated exactly like an unconfigured one here — the
// request falls through to the same unknown-route 404 below,
// without even reaching auth.identify.
if _, known := g.adapters[providerName]; known && providerPassthroughEnabled(g.cfg, providerName) {
u, grp, ok := g.auth.identify(r)
g.logAuthEvent(ok, authEventUserName(u), r)
if !ok {
writeOAIError(sw, http.StatusUnauthorized, "authentication_error", "invalid or missing API key")
return
}
g.handlePassthrough(sw, r, u, grp, providerName, rest)
return
}
}
if g.cfg.PassthroughUnknown {
g.next.ServeHTTP(sw, r)
return
}
writeOAIError(sw, http.StatusNotFound, "invalid_request_error", "unknown route")
}