Files
lukaszraczylo 4c01c75276 fix(gateway): close billing, limit, routing and translation gaps
Billing:
- Passthrough SSE streams now bill the usage the upstream reports.
  The gateway falls back to a request-size estimate only on a 2xx
  stream or a truncated JSON body.
- A unified stream that fails after it starts is estimated, not
  billed at zero.
- Billing uses the built-in LiteLLM price table and honours
  modelMeta free: true.
- Pricing entries are validated. Cost arithmetic rounds the total and
  saturates on overflow.
- Gemini thinking tokens count as completion tokens.

Limits and storage:
- A rejected request rolls back its counts on the other scopes, so one
  user cannot lock out a group.
- Redis EXPIRE replies are checked. Keys recreated without a TTL are
  repaired at once.
- A mutating pipeline is not retried after reply bytes arrive.
- History, model totals and target usage report a store outage.

Routing and translation:
- Discovered ids with a provider-like prefix are listed only in
  owner/id form, so listing and routing agree.
- Anthropic and Gemini discovery follows pagination.
- The developer role maps to system.
- stream_options.include_usage is honoured on translated streams.
- tool_choice none keeps tools when history holds tool blocks.
- refusal maps to content_filter. Data URI parameters are stripped.

Streaming, retry and failover:
- SSE accepts CR, LF and CRLF line ends. This closes a CR injection
  path.
- Retry-After is never jittered. A cancelled wait returns the
  context error.
- The watchdog timer stops at end of body.
- Oversized bodies return 413.
- A failed stream ends with an error event.
- Failover restores pre-existing headers. Client cancels do not count
  as provider successes.

Also: "admin" is a reserved provider name. metrics.path must start
with "/". Pricing warnings go to the right gateway instance. The admin
targets view sends access as null or [] so the two states differ.
Upstream organisation headers are stripped. The users-file reload
error is logged once per error. Dead code is removed, and test-only
helpers move to test files.

Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
2026-09-23 09:44:31 +01:00

698 lines
34 KiB
Go

package traefikllmgateway
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
)
// Provider type identifiers, matched against ProviderConfig.Type.
const (
providerTypeOpenAI = "openai"
providerTypeAnthropic = "anthropic"
providerTypeGemini = "gemini"
)
// Default base URLs applied by buildAdapters when a ProviderConfig omits
// BaseURL, keyed by provider type.
const (
defaultBaseOpenAI = "https://api.openai.com"
defaultBaseAnthropic = "https://api.anthropic.com"
defaultBaseGemini = "https://generativelanguage.googleapis.com"
)
// defaultBaseURLByType maps a provider type to its default base URL. It is
// read-only after package initialization, the same pattern pricing.go uses
// for builtinPricing.
var defaultBaseURLByType = map[string]string{
providerTypeOpenAI: defaultBaseOpenAI,
providerTypeAnthropic: defaultBaseAnthropic,
providerTypeGemini: defaultBaseGemini,
}
// maxResponseBytes caps how much of a non-streaming upstream response body
// an adapter reads into memory: 32MiB, generous for a chat completion or
// embeddings JSON body while bounding memory against a misbehaving or
// oversized upstream response.
const maxResponseBytes = 32 << 20
// adapterIdleConnsPerHost bounds each adapter's shared http.Client's idle
// connection pool for its single upstream host, so repeated requests reuse
// connections instead of paying a fresh TLS handshake per call.
const adapterIdleConnsPerHost = 16
// providerHTTPError reports a non-2xx response from an upstream provider.
// Adapters return it instead of writing the error to the client themselves,
// so the caller — the unified route in a later task — decides whether to
// pass the body through verbatim or wrap it in the gateway's own error
// envelope.
//
// MUST be returned bare, never wrapped with fmt.Errorf("%w", ...):
// routes_unified.go's handleAdapterError relies on a direct type assertion
// (err.(*providerHTTPError)), not errors.As, because errors.As panics under
// yaegi when checking whether an interpreted pointer type implements error
// (verified empirically under real Traefik, Task 15's integration suite).
// A wrapped providerHTTPError would make that assertion fail to find it.
type providerHTTPError struct {
body []byte
status int
}
// Error implements the error interface. It deliberately omits body: the
// caller that inspects providerHTTPError.body decides what, if anything, of
// the upstream response to surface.
func (e *providerHTTPError) Error() string {
return fmt.Sprintf("provider returned status %d", e.status)
}
// newProviderHTTPError builds a providerHTTPError from a non-2xx upstream
// response, reading its body (capped at maxResponseBytes) before the caller
// closes it.
func newProviderHTTPError(resp *http.Response) *providerHTTPError {
body, _ := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
return &providerHTTPError{status: resp.StatusCode, body: body}
}
// readAllHintMax caps how much readAllLimited allocates up front from a
// declared Content-Length. A peer that declares a large length and then
// stalls must not make the gateway hold more than this before any byte
// arrives; a longer body still reads correctly, it just regrows past it.
const readAllHintMax = 1 << 20
// readAllLimited returns the same result as io.ReadAll(io.LimitReader(r,
// limit)); contentLength is only a capacity hint — a wrong value still
// reads to EOF or limit, never trusted for correctness. Used for upstream
// response bodies (forwardJSON), where the sender is the operator-
// configured provider, not an arbitrary client. When it looks usable, it
// lets a body of known length be read into a single allocation instead of
// io.ReadAll's repeated doubling regrowth, but the hint itself is clamped
// to readAllHintMax before allocating — the added bytes.MinRead keeps
// ReadFrom's final probe read (which always tries to read past what it
// expects) from itself forcing one more regrow.
func readAllLimited(r io.Reader, contentLength, limit int64) ([]byte, error) {
if contentLength <= 0 || contentLength > limit {
return io.ReadAll(io.LimitReader(r, limit))
}
hint := contentLength
if hint > readAllHintMax {
hint = readAllHintMax
}
buf := bytes.NewBuffer(make([]byte, 0, hint+bytes.MinRead))
_, err := buf.ReadFrom(io.LimitReader(r, limit))
return buf.Bytes(), err
}
// errUpstream is a sentinel wrapped into errors an adapter returns when it
// cannot reach, or cannot make sense of a response from, its upstream —
// distinct from providerHTTPError, which means the upstream answered, just
// with a non-2xx status.
var errUpstream = errors.New("llmgateway: upstream request failed")
// errRequestBuildFailed marks an upstreamJSON failure that happened
// before any network I/O — http.NewRequestWithContext rejected the
// method or URL. It is chained alongside errUpstream (Go's multi-%w), so
// an existing errors.Is(err, errUpstream) check still matches, while
// retryPolicy's isTransient (retry.go) checks specifically for this
// sentinel to treat it as non-transient: retrying an identically
// malformed request produces the identical failure every time, so a
// retry only wastes attempts.
var errRequestBuildFailed = errors.New("llmgateway: build upstream request failed")
// providerAdapter is the gateway's uniform interface over one configured
// upstream LLM provider. Task 9 (anthropic) and Task 10 (gemini) implement
// it alongside this task's openai-type adapter; Tasks 11-12 wire a built
// map of these into the request routing path.
type providerAdapter interface {
// name returns the provider's configured name (the key under
// Config.Providers).
name() string
// typeName returns the provider's configured type, e.g. "openai".
typeName() string
// base returns the provider's resolved base URL, trailing slash
// trimmed.
base() string
// injectAuth sets whatever headers r needs to authenticate against the
// provider. Behavior is type-specific: an openai-type adapter treats
// a resolved-empty API key as a valid keyless upstream, so injectAuth
// is a no-op in that case. An anthropic-type adapter's constructor
// rejects an empty key outright (ruling c), so its injectAuth always
// sets auth headers — there is no keyless case for it to be a no-op
// over.
injectAuth(r *http.Request)
// chatCompletion proxies req to the provider's chat completions
// endpoint and writes the (possibly streamed) response to w. req has
// already had its "model" field rewritten to the provider's own model
// id by the caller. On a non-2xx upstream response, chatCompletion
// writes nothing to w and returns a *providerHTTPError instead.
chatCompletion(ctx context.Context, w http.ResponseWriter, req map[string]any) (usage, error)
// embeddings mirrors chatCompletion for the provider's embeddings
// endpoint. It never streams.
embeddings(ctx context.Context, w http.ResponseWriter, req map[string]any) (usage, error)
// listModels returns the provider's available model ids, for discovery.
listModels(ctx context.Context) ([]string, error)
// imagesGeneration proxies req to the provider's image-generation
// endpoint and writes the response to w (spec §3, v0.2). req has
// already had its "model" field rewritten to the provider's own model
// id by the caller, matching chatCompletion/embeddings' contract.
// Images are never cached (spec §2) and never cost-accounted (spec
// §3) — every implementation returns a zero usage regardless of what
// its upstream reports. Anthropic has no images API and always
// returns a *translateError with notSupported set, mapped to HTTP 501
// by the same handleAdapterError chatCompletion/embeddings errors
// already go through.
imagesGeneration(ctx context.Context, w http.ResponseWriter, req map[string]any) (usage, error)
// audioSpeech proxies body — a JSON request the caller has already
// re-marshaled with "model" rewritten to the provider's own model id
// — to the provider's text-to-speech endpoint, streaming the (binary)
// response to w as it arrives rather than buffering it in full (spec
// §3, v0.2). Gemini and Anthropic have no OpenAI-compatible TTS
// endpoint and always return a *translateError with notSupported set.
audioSpeech(ctx context.Context, w http.ResponseWriter, body []byte, contentType string) (usage, error)
// audioTranscription proxies body — the client's original raw
// multipart request, replayed unchanged — to the provider's
// speech-to-text endpoint, forwarding its (JSON or text) response to w
// verbatim (spec §3, v0.2). Gemini and Anthropic have no
// OpenAI-compatible STT endpoint and always return a *translateError
// with notSupported set.
audioTranscription(ctx context.Context, w http.ResponseWriter, body []byte, contentType string) (usage, error)
// httpClient returns the adapter's shared *http.Client, so a caller
// outside this file (the native passthrough route, routes_passthrough.go)
// can issue upstream requests through the same connection-pooled client
// every other adapter call uses, instead of building a fresh one per
// request. Named httpClient rather than client to avoid colliding with
// every implementation's own "client" struct field of the same name.
httpClient() *http.Client
// requestTimeout returns this adapter's resolved per-request timeout
// (Config.RequestTimeout or ProviderConfig.RequestTimeout,
// resolveProviderTimeout in timeout.go) — the idle-progress bound every
// upstream call through this adapter's shared client is held to.
// Exposed on the interface so a caller outside this file (native
// passthrough, routes_passthrough.go's handlePassthrough) can apply the
// identical watchdog proxyUpstream needs to its own resp.Body read,
// instead of the timeout only ever protecting upstreamJSON/
// upstreamRawBytes' own callers.
requestTimeout() time.Duration
}
// newAdapterHTTPClient returns an *http.Client for a provider adapter to
// hold and reuse across every request it makes: no CLIENT-level timeout
// (Client.Timeout is deliberately left unset), since that would bound the
// whole request including body read — killing exactly the long streams
// this gateway must preserve (a 20-minute generation that keeps producing
// tokens must complete) — and a Transport cloned from http.DefaultTransport
// with a larger per-host idle connection pool, since every request from
// one adapter targets the same upstream host.
//
// timeout sets Transport.ResponseHeaderTimeout: the first of this
// feature's two mechanisms — it bounds only the wait for a response's
// headers, never the body, so it is safe for streaming. The second
// mechanism, bounding idle time WITHIN the body (a provider that sends
// headers and then stalls), is watchdogBody (timeout.go), applied by each
// caller of upstreamBytes/proxyUpstream to the *http.Response.Body this
// client's Do returns — Transport has no equivalent field for that half.
func newAdapterHTTPClient(timeout time.Duration) *http.Client {
tr, ok := http.DefaultTransport.(*http.Transport)
if ok {
tr = tr.Clone()
} else {
// Defensive fallback: something in the shared Traefik process
// replaced http.DefaultTransport with a type that is not
// *http.Transport. A fresh zero-value Transport (Go's own defaults)
// is safer than panicking at adapter construction over a type
// assertion this package does not control.
tr = &http.Transport{}
}
tr.MaxIdleConnsPerHost = adapterIdleConnsPerHost
tr.ResponseHeaderTimeout = timeout
return &http.Client{Transport: tr}
}
// attemptRecorder is invoked once per upstream HTTP attempt a shared
// chokepoint makes — retryPolicy.do's own retry loop (retry.go), for every
// adapter method that goes through upstreamJSON/upstreamRawBytes, and
// proxyUpstream's single client.Do call (routes_passthrough.go) — with
// that attempt's raw resp/err, for Feature A's (v0.22) per-provider
// success-rate accounting (limiter.recordProviderAttempt, limits.go). A
// recorder must never touch resp.Body (still owned by its caller, read or
// forwarded afterward) and must never retain resp or err past the call.
type attemptRecorder func(resp *http.Response, err error)
// attemptRecorderCtxKey is the unexported context.Value key
// withAttemptRecorder/attemptRecorderFromContext share. An unexported
// struct type, not a string, so no other package's context.WithValue call
// can ever collide with it by accident.
type attemptRecorderCtxKey struct{}
// withAttemptRecorder returns a context carrying rec, so a chokepoint
// shared across call sites that must NOT all record provider accounting —
// retryPolicy.do and proxyUpstream are both also reached by traffic
// Feature A is deliberately out of scope for: registry.go's discovery
// listModels calls (no recorder ever set on their ctx) and the MCP/A2A
// target proxy (handleTargetProxy, mcp_a2a.go, which never wraps its
// request's context this way) — can look the recorder up without either
// function needing a dedicated parameter every call site would otherwise
// have to pass. A nil rec returns ctx unchanged, so a caller may call this
// unconditionally.
func withAttemptRecorder(ctx context.Context, rec attemptRecorder) context.Context {
if rec == nil {
return ctx
}
return context.WithValue(ctx, attemptRecorderCtxKey{}, rec)
}
// attemptRecorderFromContext returns the attemptRecorder ctx carries via
// withAttemptRecorder, or nil when none was set — the common case for any
// call path Feature A does not account (see withAttemptRecorder's doc
// comment). A bare comma-ok type assertion, not errors.As or any reflect-
// based check: see providerHTTPError's own doc comment for why a
// yaegi-interpreted plugin must avoid errors.As here.
func attemptRecorderFromContext(ctx context.Context) attemptRecorder {
rec, _ := ctx.Value(attemptRecorderCtxKey{}).(attemptRecorder)
return rec
}
// latencyRecorder is invoked exactly once per completed upstream body —
// from watchdogBody.Close (timeout.go), never per Read — with the
// latencySample (metrics.go) that body's own watchdog measured: time to
// first byte, total duration, and whether the response was a stream
// (feat: instrument upstream latency, observation-only per the task
// brief — no throttling or control logic reads this).
//
// Threaded via context exactly like attemptRecorder above, for the same
// reason: upstreamBytes/proxyUpstream have no other way to reach
// Gateway-level state (g.latency, metrics.go). A call site that never
// wires one via withLatencyRecorder gets nil back from
// latencyRecorderFromContext, so registry.go's discovery listModels
// calls and the MCP/A2A target proxy (handleTargetProxy, mcp_a2a.go —
// neither ever wraps its context this way, mirroring
// withAttemptRecorder's own identical exclusion) never observe latency
// either — this feature accounts the same traffic Feature A does, not a
// byte more.
type latencyRecorder func(sample latencySample)
// latencyRecorderCtxKey is the unexported context.Value key
// withLatencyRecorder/latencyRecorderFromContext share — its own type,
// never a string, so no other package's context.WithValue call can
// collide with it, mirroring attemptRecorderCtxKey above.
//
// Deliberately NOT a bare `struct{}` like attemptRecorderCtxKey (yaegi-
// check finding, this feature): under the REAL Yaegi interpreter, two
// DISTINCT zero-sized struct types used as `any`-typed context.WithValue
// keys compare EQUAL to each other via `==` — an interpreter-only
// divergence from compiled Go's own interface-equality semantics, where
// two values of different concrete types are never equal regardless of
// value. With attemptRecorderCtxKey ALSO a bare struct{}, a context
// carrying both (routes_unified.go's withAttemptRecorder then
// withLatencyRecorder) let ctx.Value(attemptRecorderCtxKey{}) wrongly
// match the LATER latencyRecorderCtxKey{} entry instead, silently
// returning nil once type-asserted to attemptRecorder — retryPolicy.do
// (retry.go) then never called rec(resp, err) at all, and
// limiter.recordProviderAttempt (Feature A) went permanently dark for
// every metered request, with no error anywhere: exerciseAttemptAccounting
// (tools/yaegi-check/main.go) is what caught it — a compiled `go test`
// run never sees this divergence, since compiled Go's interface equality
// is correct. The `_ byte` field costs nothing at runtime and makes this
// type's size differ from attemptRecorderCtxKey's, which is enough to
// keep the two apart under the interpreter too.
type latencyRecorderCtxKey struct{ _ byte }
// withLatencyRecorder returns a context carrying rec. A nil rec (the
// common case: metrics.go's own callers only ever pass a non-nil one
// when metricsEnabled(g.cfg) was true at the point of the call) returns
// ctx unchanged, so a caller may call this unconditionally, exactly
// mirroring withAttemptRecorder's own nil handling above.
func withLatencyRecorder(ctx context.Context, rec latencyRecorder) context.Context {
if rec == nil {
return ctx
}
return context.WithValue(ctx, latencyRecorderCtxKey{}, rec)
}
// latencyRecorderFromContext returns the latencyRecorder ctx carries via
// withLatencyRecorder, or nil when none was set — the common case for
// any call path this feature does not account, or for any deployment
// with metrics collection disabled (see latencyRecorder's own doc
// comment).
func latencyRecorderFromContext(ctx context.Context) latencyRecorder {
rec, _ := ctx.Value(latencyRecorderCtxKey{}).(latencyRecorder)
return rec
}
// upstreamJSON issues an HTTP request to url: body, when non-nil, is
// marshaled as the JSON request body; hdr's values are added to the
// request (a caller builds this from its adapter's auth and content-type
// headers). It returns the raw *http.Response for the caller to inspect
// the status code and read or stream the body from — closing resp.Body is
// the caller's responsibility. A failure to encode the body, build the
// request, or reach the upstream returns an error wrapping errUpstream;
// a non-2xx status is not itself an error here, since some callers (e.g.
// listModels) have nothing to write through and every current caller
// checks resp.StatusCode itself.
//
// timeout and providerName (an adapter's own resolved requestTimeout()/
// name()) arm the idle-progress watchdog upstreamBytes wraps resp.Body
// in, once a response is actually obtained — see watchdogBody's own doc
// comment, timeout.go, for the mechanism and why it, not
// http.Client.Timeout, is what bounds a stalled body.
//
// policy (retry.go) wraps the whole request-and-receive-status exchange:
// a nil policy, or a disabled one, makes exactly one attempt (v0.1
// behavior, byte-identical). Every caller of upstreamJSON — chatCompletion,
// embeddings, listModels, across all three adapter types — sits upstream
// of any response-body forwarding (forwardJSON/forwardStream), so a retry
// here can only ever replace an attempt whose body no caller has read or
// forwarded yet. That is what makes "zero bytes reached the client" hold
// by construction: forwardStream is invoked only after upstreamJSON has
// already returned its final, retried-or-not response, so a stream that
// dies while forwardStream is mid-flight never re-enters this function
// and is therefore never retried.
func upstreamJSON(ctx context.Context, client *http.Client, method, url string, hdr http.Header, body any, policy *retryPolicy, timeout time.Duration, providerName string) (*http.Response, error) {
var bodyBytes []byte
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("%w: encode request body: %w", errUpstream, err)
}
bodyBytes = b
}
return upstreamBytes(ctx, client, method, url, hdr, bodyBytes, policy, timeout, providerName)
}
// upstreamRawBytes is upstreamJSON's counterpart for a body the caller has
// already encoded: the audio endpoints' openai-type adapter methods
// (provider_openai.go's audioSpeech and audioTranscription) call this
// directly with a body that must never be marshaled again — audioSpeech's
// is a JSON request the caller (routes_media.go) already re-marshaled
// once after rewriting "model", and audioTranscription's is a raw
// multipart payload upstreamJSON's json.Marshal would corrupt outright.
// Shares upstreamBytes with upstreamJSON, so both get the identical
// retry/zero-bytes-reached invariant documented on upstreamJSON.
func upstreamRawBytes(ctx context.Context, client *http.Client, method, url string, hdr http.Header, body []byte, policy *retryPolicy, timeout time.Duration, providerName string) (*http.Response, error) {
return upstreamBytes(ctx, client, method, url, hdr, body, policy, timeout, providerName)
}
// upstreamBytes is the shared request-build-and-send core behind both
// upstreamJSON (which marshals body to JSON first) and upstreamRawBytes
// (which sends an already-encoded body verbatim): hdr's values are added
// to the request, bodyBytes (nil for a body-less request) becomes the
// request body unchanged, and Content-Type defaults to
// "application/json" only when bodyBytes is non-nil and hdr set none of
// its own.
func upstreamBytes(ctx context.Context, client *http.Client, method, url string, hdr http.Header, bodyBytes []byte, policy *retryPolicy, timeout time.Duration, providerName string) (*http.Response, error) {
// rec is looked up once, outside the retry loop's call closure: every
// attempt shares the identical ctx, so the recorder (nil when no
// caller ever wired one via withAttemptRecorder) is the same across
// attempts. watchdogBody uses it too (coordinator adversarial review,
// 2026-08-23, finding F4): retryPolicy.do's own rec(resp, err) call
// (retry.go) already ran, at header-arrival time, before any byte of
// the body was read — a mid-body stall was otherwise invisible to
// provider-health accounting, since nothing else ever called rec
// again for that attempt.
rec := attemptRecorderFromContext(ctx)
// latRec: same one-lookup-outside-the-retry-loop shape as rec above,
// and the same reasoning applies — every attempt shares ctx, so a
// latencyRecorder wired via withLatencyRecorder (nil when metrics
// collection is disabled, latencyRecorder's own doc comment) is
// identical across attempts. A retried request still gets its own
// independent latency observation per attempt (below, start is
// captured fresh inside the closure), matching how watchdogBody
// itself is rebuilt fresh per attempt.
latRec := latencyRecorderFromContext(ctx)
call := func() (*http.Response, error) {
var r io.Reader
if bodyBytes != nil {
r = bytes.NewReader(bodyBytes)
}
// reqCtx/cancel bound this attempt's response body to timeout via
// watchdogBody (timeout.go), derived fresh per attempt so a retried
// request gets its own full timeout window rather than inheriting
// an already-ticking one. cancel is released either by
// watchdogBody (once resp.Body exists — see its own Close/fire) or
// directly below, on a build/send failure that never produced a
// body to wrap.
reqCtx, cancel := context.WithCancel(ctx)
// gosec G704 (SSRF via taint analysis) flags url below: every call
// site builds it from an operator-configured value —
// adapter.baseURL (ProviderConfig.BaseURL, or defaultBaseURLByType
// when omitted) concatenated with a fixed, hardcoded path string —
// never anything request-derived that could change the scheme or
// host. Gemini's endpoints additionally embed a url.PathEscape'd
// model id mid-path (provider_gemini.go), which cannot introduce a
// new host or scheme either. See routes_passthrough.go's
// proxyUpstream for the one adapter call path where a request-
// derived path segment genuinely reaches the outgoing URL, and its
// own comment for why that case is safe.
req, err := http.NewRequestWithContext(reqCtx, method, url, r) //nolint:gosec // operator-configured host; see comment above
if err != nil {
cancel()
return nil, fmt.Errorf("%w: %w: build request: %w", errUpstream, errRequestBuildFailed, err)
}
for k, vs := range hdr {
for _, v := range vs {
req.Header.Add(k, v)
}
}
if bodyBytes != nil && req.Header.Get("Content-Type") == "" {
req.Header.Set("Content-Type", "application/json")
}
// start: captured just before the request is actually sent, so a
// non-nil latRec's eventual sample (armLatency below) measures from
// here — matching the task brief's own "from just before the
// upstream request is sent" definition, not from whatever moment
// newWatchdogBody happens to run at (which is already after
// headers arrived).
start := time.Now()
resp, err := client.Do(req) //nolint:bodyclose,gosec // caller closes resp.Body; upstreamBytes hands the response, not its lifecycle, back — same operator-configured URL as above
if err != nil {
cancel()
return nil, fmt.Errorf("%w: %w", errUpstream, err)
}
wb := newWatchdogBody(resp.Body, cancel, timeout, fmt.Sprintf("provider %q", providerName), rec)
if latRec != nil {
// streaming: derived from resp's own Content-Type, the SAME
// convention each adapter's forwardStream/forwardJSON split
// already applies (isEventStreamResponse, timeout.go) — decided
// here because it is already knowable, ahead of whichever of
// those two the caller picks moments from now.
wb.armLatency(start, isEventStreamResponse(resp), latRec)
}
resp.Body = wb
return resp, nil
}
return policy.do(ctx, call)
}
// configNamePattern is the character set allowed in a provider, mcpServers,
// or agents config key: it is embedded verbatim as a path segment in the
// gateway's own routes (passthroughRoute's "/{provider}/*",
// targetRoute's "/mcp/{name}/*" and "/a2a/{name}/*"), so it is restricted to
// characters safe there rather than the full range a Go map key allows.
var configNamePattern = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// reservedConfigNames are the provider, mcpServers, and agents names
// rejected at construction because they would shadow one of the gateway's
// own fixed top-level routes: "v1" is the unified API namespace
// (/v1/chat/completions, /v1/models, /v1/embeddings, /v1/mcp/servers,
// /v1/agents), "mcp" and "a2a" are the MCP/A2A target-proxy prefixes
// (/mcp/{name}/..., /a2a/{name}/...), and "admin" for the dashboard's own
// fixed /admin, /admin/assets/*, and /admin/api/* routes (review-auth.md
// finding F9: a provider literally named "admin" would otherwise shadow
// every one of those, an easy config typo away). ServeHTTP checks those
// fixed routes ahead of passthrough/target routing (llmgateway.go), so a
// provider, MCP server, or agent configured under one of these names
// would never be reachable — reject it here instead of silently
// shadowing it. Reserved unconditionally, the same as "v1"/"mcp"/"a2a"
// already are, rather than only when admin.enabled: a provider named
// "admin" configured before the dashboard is turned on would otherwise
// silently lose its own routes the moment someone later enables it.
var reservedConfigNames = map[string]bool{"v1": true, "mcp": true, "a2a": true, "admin": true}
// dotOnlyConfigNames are the two names configNamePattern's character class
// permits (it allows ".") but that are rejected anyway: "." and ".." read
// as directory-traversal segments once embedded as a path segment in the
// gateway's own routes (passthroughRoute's "/{provider}/*", targetRoute's
// "/mcp/{name}/*" and "/a2a/{name}/*"), even though nothing in this
// package's own path handling currently mis-resolves them — disallowing
// them at construction is cheap, and removes any dependence on that
// staying true.
var dotOnlyConfigNames = map[string]bool{".": true, "..": true}
// validateConfigName reports an error unless name matches configNamePattern,
// is not one of dotOnlyConfigNames, and is not one of reservedConfigNames.
// kind labels the config section (e.g. "provider", "mcpServers", "agents")
// in the error message.
func validateConfigName(kind, name string) error {
if !configNamePattern.MatchString(name) {
return fmt.Errorf("llmgateway: %s name %q is invalid: must match %s", kind, name, configNamePattern.String())
}
if dotOnlyConfigNames[name] {
return fmt.Errorf("llmgateway: %s name %q is invalid: must not be \".\" or \"..\"", kind, name)
}
if reservedConfigNames[name] {
return fmt.Errorf("llmgateway: %s name %q is reserved and would shadow a gateway route", kind, name)
}
return nil
}
// validateMetadataPath rejects a non-empty ProviderConfig.MetadataPath
// (feature v0.23) that does not start with "/" (review fix, folded
// minor) — fetchModelMetadata (provider_openai.go) builds its request
// URL by plain string concatenation, a.baseURL+a.metadataPath; a value
// missing the leading slash would silently glue onto baseURL's own path
// with no separator (e.g. "http://host:1234api/v0/models"), a malformed
// URL that only surfaces as a confusing runtime fetch failure instead of
// a clear, named construction error. Empty (the default, metadata
// capture disabled) is always valid.
func validateMetadataPath(providerName, metadataPath string) error {
if metadataPath == "" {
return nil
}
if !strings.HasPrefix(metadataPath, "/") {
return fmt.Errorf("llmgateway: provider %q: metadataPath %q must start with \"/\"", providerName, metadataPath)
}
return nil
}
// providerPassthroughEnabled reports whether providerName's native
// passthrough route is reachable (security+performance audit,
// 2026-08-22): true when ProviderConfig.Passthrough is nil (the default)
// or explicitly true; false only when an operator set it to false.
// cfg.Providers[providerName] is assumed to already be a known key —
// ServeHTTP's own passthrough route gate (llmgateway.go) checks
// g.adapters[providerName] first, and buildAdapters guarantees adapters
// and cfg.Providers share the same key set — but a missing key still
// defaults to true here rather than panicking, matching every other
// nil-means-default helper in this package.
func providerPassthroughEnabled(cfg *Config, providerName string) bool {
pc, ok := cfg.Providers[providerName]
if !ok || pc.Passthrough == nil {
return true
}
return *pc.Passthrough
}
// buildAdapters resolves cfg.Providers into a map of providerAdapter keyed
// by provider name. Every provider name is validated (validateConfigName)
// and every ProviderConfig value must be non-nil before anything else runs,
// so a malformed map key or a nil map value fails construction with a clear
// error instead of a nil-pointer panic. For each provider it resolves
// APIKey via resolveSecret (propagating that error) and defaults BaseURL by
// provider type when the config omits one, trimming any trailing slash
// either way. An unknown provider type is a config error. A resolved empty
// key is a valid keyless upstream for openai-type providers, but a
// constructor error for anthropic-type ones — newAnthropicAdapter enforces
// that, per ruling (c); this function just propagates whatever error it
// returns. A configured gemini provider follows the same constructor-error
// ruling — newGeminiAdapter enforces it too.
//
// buildAdapters also derives cfg.Retry into a single *retryPolicy
// (newRetryPolicy, retry.go — an invalid Retry config fails construction
// the same way any other bad config value does) and attaches it to every
// adapter it constructs. Deriving it here, from cfg, rather than taking it
// as a parameter built upstream in newGateway, keeps buildAdapters'
// signature at its v0.1 shape — buildAdapters(cfg *Config) — so every
// existing direct call to it (production and test) keeps compiling and
// behaving identically when cfg.Retry is left at its zero value.
func buildAdapters(cfg *Config) (map[string]providerAdapter, error) {
policy, err := newRetryPolicy(cfg.Retry)
if err != nil {
return nil, err
}
// globalTimeout is resolved once, up front, so a malformed
// Config.RequestTimeout fails construction even for a deployment where
// every provider happens to set its own ProviderConfig.RequestTimeout
// override — resolveProviderTimeout's own doc comment (timeout.go).
// timeoutErr, not err (govet shadow): a second err reused across this
// function's top-level scope made every err re-declared inside the
// provider loop below read as a suspicious shadow.
globalTimeout, timeoutErr := resolveRequestTimeout(cfg.RequestTimeout, "requestTimeout")
if timeoutErr != nil {
return nil, timeoutErr
}
adapters := make(map[string]providerAdapter, len(cfg.Providers))
for name, pc := range cfg.Providers {
if pc == nil {
return nil, fmt.Errorf("llmgateway: provider %q: config must not be nil", name)
}
if err := validateConfigName("provider", name); err != nil {
return nil, err
}
apiKey, err := resolveSecret(pc.APIKey)
if err != nil {
return nil, fmt.Errorf("llmgateway: provider %q: %w", name, err)
}
base := strings.TrimSuffix(pc.BaseURL, "/")
if base == "" {
base = defaultBaseURLByType[pc.Type]
}
if err := validateMetadataPath(name, pc.MetadataPath); err != nil {
return nil, err
}
// timeoutErr, not err (govet shadow — see globalTimeout's identical
// note above): this loop already declares its own err via
// apiKey, err := resolveSecret above; reusing it here for a second,
// later purpose is what made validateMetadataPath's unrelated
// if err := ...; err != nil read as a suspicious shadow of it.
timeout, timeoutErr := resolveProviderTimeout(name, pc.RequestTimeout, globalTimeout)
if timeoutErr != nil {
return nil, timeoutErr
}
switch pc.Type {
case providerTypeOpenAI:
a := newOpenAIAdapter(name, base, apiKey)
a.retry = policy
a.metadataPath = pc.MetadataPath
// timeout/client are re-resolved here rather than left at the
// bare constructor's defaultRequestTimeout default (mirroring
// how a.retry/a.metadataPath are also only ever set to their
// real, config-derived values here, not by newOpenAIAdapter
// itself) — client is rebuilt, not mutated in place, since
// Transport.ResponseHeaderTimeout is set once at construction
// (newAdapterHTTPClient, above) and every adapter's Transport
// is otherwise identical.
a.timeout = timeout
a.client = newAdapterHTTPClient(timeout)
adapters[name] = a
case providerTypeAnthropic:
a, err := newAnthropicAdapter(name, base, apiKey)
if err != nil {
return nil, err
}
a.retry = policy
a.timeout = timeout
a.client = newAdapterHTTPClient(timeout)
adapters[name] = a
case providerTypeGemini:
g, err := newGeminiAdapter(name, base, apiKey)
if err != nil {
return nil, err
}
g.retry = policy
g.timeout = timeout
g.client = newAdapterHTTPClient(timeout)
adapters[name] = g
default:
return nil, fmt.Errorf("llmgateway: provider %q: unknown type %q", name, pc.Type)
}
}
return adapters, nil
}