mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
Controller-ruled small fixes accumulated from T3/T4 reviews: 1. routes_media.go: multipart Content-Type check is now an exact mediaType != "multipart/form-data" comparison (HasPrefix admitted "multipart/form-datax"); a malformed-tail multipart body (fails to parse at all) now reports "malformed multipart body", distinct from "model is required" (a body that parses fine but carries no model part) and "duplicate model field". 2. provider_openai.go: audioTranscription's doc comment no longer claims the body is always "replayed unchanged"/the boundary always "verbatim" — it now reflects the rewrite path routes_media.go already takes for an aliased model id. 3. rewriteMultipartModel: buf.Grow(len(body)+512) up front, avoiding repeated reallocate-and-copy growth while rebuilding an aliased multipart body. 4. limits.go currentUsage: guards len(vals) == len(keys) before indexing storeGetMulti's result, so a non-conforming counterStore's short slice reports storeDown instead of panicking. 5. admin.go dashboard JS: the 5s poll no longer wipes an existing auth-error message when no key is stored (only a successful load or a new submission clears it now); clearStoredKey on a 401/403 now only fires when the failing request's own key still matches what's currently stored, guarding against a late response for a key the user has since replaced. 6. Spec §4's duplicated /admin/api/overview bullet and the nosniff/no-store sentence's placement were already fixed upstream (docs/superpowers/specs/2026-08-20-llmgateway-v02-design.md, commit c02010d) — verified identical, nothing further to commit here. 7. Added TestAdmin_JSONSecurityHeaders (nosniff/no-store/CSP on both admin JSON APIs) and TestHandleAudioTranscriptions_MalformedTail_400 (covers fix 1's new malformed-body branch). go test ./... -count=1 -race, gofmt, vet, make yaegi-check, golangci-lint (incl. gosec) all pass.
372 lines
15 KiB
Go
372 lines
15 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"mime"
|
|
"mime/multipart"
|
|
"net/http"
|
|
)
|
|
|
|
// Route paths for the three unified media endpoints (spec §3, v0.2):
|
|
// image generation and both audio endpoints are openai-type-only native
|
|
// forwards, except images.generations, which gemini translates to Imagen
|
|
// (translate_gemini_images.go); anthropic answers 501 on all three
|
|
// (provider_anthropic.go). See llmgateway.go's ServeHTTP dispatch.
|
|
const (
|
|
imagesGenerationsPath = "/v1/images/generations"
|
|
audioSpeechPath = "/v1/audio/speech"
|
|
audioTranscriptionsPath = "/v1/audio/transcriptions"
|
|
)
|
|
|
|
// maxMultipartModelFieldBytes caps how much of the "model" form field
|
|
// extractMultipartModel reads: a model id is always a short identifier,
|
|
// so this is generous while bounding memory against a form field a
|
|
// misbehaving or malicious client names "model" but fills with megabytes
|
|
// of data.
|
|
const maxMultipartModelFieldBytes = 4096
|
|
|
|
// decodeMediaJSONRequest reads r.Body capped at maxRequestBytes (routes_
|
|
// unified.go's cap, shared here) and decodes it as a JSON object, then
|
|
// extracts its "model" field. It mirrors runUnified's own decode path
|
|
// exactly (spec §3's "reuse the unified decode path") — used by both the
|
|
// images and audio-speech routes, whose request bodies are both single
|
|
// JSON objects carrying a top-level "model" field, unlike
|
|
// audio-transcriptions' multipart body. A read failure or invalid/missing
|
|
// JSON is a 400, already written to sw; ok reports whether the caller may
|
|
// proceed.
|
|
func (g *Gateway) decodeMediaJSONRequest(sw *statusTrackingWriter, r *http.Request) (req map[string]any, model string, ok bool) {
|
|
body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBytes))
|
|
if err != nil {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "cannot read request body")
|
|
return nil, "", false
|
|
}
|
|
if err = json.Unmarshal(body, &req); err != nil {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid JSON body")
|
|
return nil, "", false
|
|
}
|
|
model, _ = req["model"].(string)
|
|
if model == "" {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "model is required")
|
|
return nil, "", false
|
|
}
|
|
return req, model, true
|
|
}
|
|
|
|
// resolveMediaRequest runs the shared model-routing/authz/limits steps
|
|
// every media route needs before calling its adapter (spec §3's "auth →
|
|
// model routing (registry) → group authz → limits" — group authz is
|
|
// folded into modelRegistry.resolve itself, the same as
|
|
// routes_unified.go's runUnified). On success it returns the resolved
|
|
// adapter and the upstream model id to route to; ok reports whether the
|
|
// caller may proceed — a resolve or limit failure has already written its
|
|
// response to sw.
|
|
func (g *Gateway) resolveMediaRequest(sw *statusTrackingWriter, u *user, grp *group, requestedModel string) (adapter providerAdapter, upstreamModel string, ok bool) {
|
|
adapter, upstreamModel, _, err := g.registry.resolve(requestedModel, grp)
|
|
if err != nil {
|
|
writeModelResolveError(sw, err)
|
|
return nil, "", false
|
|
}
|
|
if violation := g.limiter.checkAndCount(buildLimitScopes(u, grp)); violation != nil {
|
|
writeLimitViolation(sw, violation)
|
|
return nil, "", false
|
|
}
|
|
return adapter, upstreamModel, true
|
|
}
|
|
|
|
// handleImagesGenerations implements POST /v1/images/generations (spec
|
|
// §3): an openai-type provider forwards natively; gemini translates to
|
|
// Imagen (translate_gemini_images.go); anthropic always answers 501
|
|
// (provider_anthropic.go's imagesGeneration). Images are never cached
|
|
// (spec §2) and never cost-accounted (spec §3) — only the request
|
|
// counters resolveMediaRequest's checkAndCount call already incremented
|
|
// move.
|
|
func (g *Gateway) handleImagesGenerations(w http.ResponseWriter, r *http.Request, u *user, grp *group) {
|
|
sw := &statusTrackingWriter{ResponseWriter: w}
|
|
|
|
req, requestedModel, ok := g.decodeMediaJSONRequest(sw, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
adapter, upstreamModel, ok := g.resolveMediaRequest(sw, u, grp, requestedModel)
|
|
if !ok {
|
|
return
|
|
}
|
|
req["model"] = upstreamModel
|
|
|
|
if _, err := adapter.imagesGeneration(r.Context(), sw, req); err != nil {
|
|
g.handleAdapterError(sw, err, adapter.name())
|
|
}
|
|
}
|
|
|
|
// handleAudioSpeech implements POST /v1/audio/speech (spec §3): an
|
|
// openai-type provider forwards natively, streaming the binary audio
|
|
// response back via a flushWriter (provider_openai.go's audioSpeech);
|
|
// gemini and anthropic both always answer 501. req is re-marshaled to
|
|
// JSON after its "model" field is rewritten to the resolved upstream
|
|
// model id, mirroring how the unified chat/embeddings routes rewrite
|
|
// "model" before handing off to their adapter — audioSpeech's own
|
|
// interface method takes a raw []byte body rather than a map
|
|
// (providers.go), since its response is binary audio no translator ever
|
|
// needs to inspect as JSON again.
|
|
func (g *Gateway) handleAudioSpeech(w http.ResponseWriter, r *http.Request, u *user, grp *group) {
|
|
sw := &statusTrackingWriter{ResponseWriter: w}
|
|
|
|
req, requestedModel, ok := g.decodeMediaJSONRequest(sw, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
adapter, upstreamModel, ok := g.resolveMediaRequest(sw, u, grp, requestedModel)
|
|
if !ok {
|
|
return
|
|
}
|
|
req["model"] = upstreamModel
|
|
// gatewayAliasKey must never reach the real provider, mirroring
|
|
// openaiAdapter.imagesGeneration's identical delete
|
|
// (provider_openai.go): routes_media.go never sets this key itself
|
|
// for a media route, but a client independently sending a literal
|
|
// "__alias" field of its own would otherwise survive the re-marshal
|
|
// below and leak straight through as an unrecognized request field.
|
|
// Unconditional, not conditional on whether the gateway set it — the
|
|
// same defensive posture chatCompletion/embeddings/imagesGeneration
|
|
// already take.
|
|
delete(req, gatewayAliasKey)
|
|
|
|
body, err := json.Marshal(req)
|
|
if err != nil {
|
|
// req decoded from the client's own JSON body plus one string-field
|
|
// overwrite ("model") — a value json.Unmarshal has already accepted
|
|
// once cannot fail to re-marshal; this is a defensive 500, not a
|
|
// path any known input reaches.
|
|
g.errorf("audio speech: re-marshal request: %v", err)
|
|
writeOAIError(sw, http.StatusInternalServerError, "server_error", "internal error")
|
|
return
|
|
}
|
|
|
|
if _, err := adapter.audioSpeech(r.Context(), sw, body, "application/json"); err != nil {
|
|
g.handleAdapterError(sw, err, adapter.name())
|
|
}
|
|
}
|
|
|
|
// handleAudioTranscriptions implements POST /v1/audio/transcriptions
|
|
// (spec §3): an openai-type provider forwards natively; gemini and
|
|
// anthropic both always answer 501. The multipart request body is
|
|
// replayed upstream unchanged only when the client's own "model" form
|
|
// field already equals the resolved upstream model id (the common case —
|
|
// a bare id); when it does not (a provider-prefixed or otherwise aliased
|
|
// id resolves to a different upstream model string), the body is
|
|
// rebuilt via rewriteMultipartModel so the alias never reaches the real
|
|
// provider, the same invariant every other unified route enforces
|
|
// (controller ruling, 2026-08-20, amends spec §3's original "REPLAY the
|
|
// full body upstream unchanged" — see rewriteMultipartModel's doc
|
|
// comment).
|
|
func (g *Gateway) handleAudioTranscriptions(w http.ResponseWriter, r *http.Request, u *user, grp *group) {
|
|
sw := &statusTrackingWriter{ResponseWriter: w}
|
|
|
|
body, oversize, err := readCapped(r.Body, maxRequestBytes)
|
|
if err != nil {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "cannot read request body")
|
|
return
|
|
}
|
|
if oversize {
|
|
writeOAIError(sw, http.StatusRequestEntityTooLarge, "invalid_request_error", "request body too large")
|
|
return
|
|
}
|
|
|
|
contentType := r.Header.Get("Content-Type")
|
|
mediaType, params, err := mime.ParseMediaType(contentType)
|
|
if err != nil {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "invalid Content-Type")
|
|
return
|
|
}
|
|
if mediaType != "multipart/form-data" {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "Content-Type must be multipart/form-data")
|
|
return
|
|
}
|
|
boundary := params["boundary"]
|
|
if boundary == "" {
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "missing multipart boundary")
|
|
return
|
|
}
|
|
|
|
// The three failure shapes below are kept distinct rather than folded
|
|
// into one "model is required" 400 (review sweep, 2026-08-20): a
|
|
// duplicate "model" part is ambiguous, a body extractMultipartModel
|
|
// cannot even parse (a malformed or truncated tail) never reached a
|
|
// point where "model" could be evaluated at all, and only a
|
|
// successfully parsed body with no "model" part is actually missing
|
|
// one — each names its own actual problem instead of collapsing every
|
|
// case into the same misleading message.
|
|
requestedModel, err := extractMultipartModel(body, boundary)
|
|
switch {
|
|
case errors.Is(err, errDuplicateModelField):
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "duplicate model field")
|
|
return
|
|
case err != nil:
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "malformed multipart body")
|
|
return
|
|
case requestedModel == "":
|
|
writeOAIError(sw, http.StatusBadRequest, "invalid_request_error", "model is required")
|
|
return
|
|
}
|
|
|
|
adapter, upstreamModel, ok := g.resolveMediaRequest(sw, u, grp, requestedModel)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
uploadBody := body
|
|
uploadContentType := contentType
|
|
if upstreamModel != requestedModel {
|
|
rebuilt, rebuiltContentType, rerr := rewriteMultipartModel(body, boundary, upstreamModel)
|
|
if rerr != nil {
|
|
g.errorf("audio transcriptions: rewrite multipart model field: %v", rerr)
|
|
writeOAIError(sw, http.StatusInternalServerError, "server_error", "internal error")
|
|
return
|
|
}
|
|
uploadBody = rebuilt
|
|
uploadContentType = rebuiltContentType
|
|
}
|
|
|
|
if _, err := adapter.audioTranscription(r.Context(), sw, uploadBody, uploadContentType); err != nil {
|
|
g.handleAdapterError(sw, err, adapter.name())
|
|
}
|
|
}
|
|
|
|
// readCapped reads up to limit+1 bytes from r, reporting oversize=true
|
|
// when more than limit bytes were available. Unlike
|
|
// decodeMediaJSONRequest's plain io.LimitReader-and-decode (which
|
|
// silently truncates an oversized JSON body into a parse failure),
|
|
// audio-transcriptions' multipart body cannot be truncated the same way —
|
|
// a cut multipart payload often still parses a valid prefix of it, which
|
|
// would forward truncated audio upstream instead of failing loudly — so
|
|
// this endpoint alone reports the cap explicitly (spec §3's "bodies above
|
|
// maxRequestBytes (10MB) → 413").
|
|
func readCapped(r io.Reader, limit int64) (body []byte, oversize bool, err error) {
|
|
body, err = io.ReadAll(io.LimitReader(r, limit+1))
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
if int64(len(body)) > limit {
|
|
return body[:limit], true, nil
|
|
}
|
|
return body, false, nil
|
|
}
|
|
|
|
// errDuplicateModelField is extractMultipartModel's sentinel for a
|
|
// multipart body carrying more than one part named "model" — an
|
|
// ambiguous request a first-found parser and a last-found parser would
|
|
// resolve differently. handleAudioTranscriptions rejects it outright
|
|
// (400) rather than silently picking one.
|
|
var errDuplicateModelField = errors.New("multipart body has more than one model field")
|
|
|
|
// extractMultipartModel scans body — a full multipart/form-data payload
|
|
// already read into memory (bounded by handleAudioTranscriptions' own
|
|
// readCapped call before this is ever reached) — for its "model" form
|
|
// field, returning the field's value. It reads over a fresh
|
|
// bytes.NewReader(body), never consuming or mutating body itself, so the
|
|
// caller can replay or rewrite the exact same bytes afterward (spec §3).
|
|
// It always scans every part, never returning as soon as the first
|
|
// "model" part is found, so a second "model" part later in the body is
|
|
// caught as errDuplicateModelField instead of being silently ignored.
|
|
// Parts are read in whatever order the client sent them — a "model"
|
|
// field following a large audio-file part is found the same as one sent
|
|
// first. Returns "" with a nil error when no part named "model" is
|
|
// present; the caller treats an empty model the same as a missing one.
|
|
func extractMultipartModel(body []byte, boundary string) (string, error) {
|
|
mr := multipart.NewReader(bytes.NewReader(body), boundary)
|
|
found := false
|
|
var model string
|
|
for {
|
|
part, err := mr.NextPart()
|
|
if errors.Is(err, io.EOF) {
|
|
return model, nil
|
|
}
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if part.FormName() != "model" {
|
|
_ = part.Close() //nolint:errcheck // read-side close; nothing actionable on failure
|
|
continue
|
|
}
|
|
if found {
|
|
_ = part.Close() //nolint:errcheck // read-side close; nothing actionable on failure
|
|
return "", errDuplicateModelField
|
|
}
|
|
val, err := io.ReadAll(io.LimitReader(part, maxMultipartModelFieldBytes))
|
|
_ = part.Close() //nolint:errcheck // read-side close; nothing actionable on failure
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
model = string(val)
|
|
found = true
|
|
}
|
|
}
|
|
|
|
// rewriteMultipartModel rebuilds body — a multipart/form-data payload
|
|
// already validated to parse and to carry exactly one "model" field
|
|
// (extractMultipartModel) — with a fresh mime/multipart.Writer, copying
|
|
// every part verbatim (its original headers and content, in the
|
|
// client's original order) except the "model" part's value, which
|
|
// becomes upstreamModel.
|
|
//
|
|
// Controller ruling (2026-08-20, amends spec §3's original "REPLAY the
|
|
// full body upstream unchanged"): a client addressing a
|
|
// provider-prefixed or otherwise aliased model id must never have that
|
|
// alias reach the real upstream provider — the same invariant every
|
|
// other unified route already enforces by rewriting "model" before
|
|
// forwarding. handleAudioTranscriptions calls this only when the
|
|
// client's own model string differs from the resolved upstream model
|
|
// id; when they already match (the bare-id case), it replays the
|
|
// original bytes untouched instead (the fast path, no rebuild cost).
|
|
//
|
|
// The rebuilt body uses the Writer's own new boundary, not body's
|
|
// original one, and contentType is that Writer's own
|
|
// FormDataContentType() — byte-identical boundary preservation is not
|
|
// required, only a valid, equivalent multipart body.
|
|
func rewriteMultipartModel(body []byte, boundary, upstreamModel string) ([]byte, string, error) {
|
|
mr := multipart.NewReader(bytes.NewReader(body), boundary)
|
|
var buf bytes.Buffer
|
|
// The rebuilt body is at least as large as the original (same parts,
|
|
// same content, only the "model" value and the boundary differ) plus
|
|
// the fresh Writer's own per-part header/boundary overhead — growing
|
|
// buf to body's length up front, with headroom, avoids the repeated
|
|
// reallocate-and-copy growth bytes.Buffer would otherwise do one
|
|
// io.Copy at a time.
|
|
buf.Grow(len(body) + 512)
|
|
mw := multipart.NewWriter(&buf)
|
|
|
|
for {
|
|
part, err := mr.NextPart()
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
|
|
pw, err := mw.CreatePart(part.Header)
|
|
if err != nil {
|
|
_ = part.Close() //nolint:errcheck // read-side close; nothing actionable on failure
|
|
return nil, "", err
|
|
}
|
|
|
|
if part.FormName() == "model" {
|
|
_, err = pw.Write([]byte(upstreamModel))
|
|
} else {
|
|
_, err = io.Copy(pw, part)
|
|
}
|
|
_ = part.Close() //nolint:errcheck // read-side close; nothing actionable on failure
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
}
|
|
|
|
if err := mw.Close(); err != nil {
|
|
return nil, "", err
|
|
}
|
|
return buf.Bytes(), mw.FormDataContentType(), nil
|
|
}
|