// 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 \"\": "), 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 // Routes retryWarmFill's "recovered after retry" success line to INFO // instead of the constructor's default ERROR (g.errorf, passed to // newModelRegistry above) — see the registry's info field doc comment. registry.info = g.logf // 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") }