Files
traefik-llmgateway/sse.go
T
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

374 lines
15 KiB
Go

package traefikllmgateway
import (
"bufio"
"bytes"
"errors"
"io"
"net/http"
"strings"
)
// sseScannerInitialBufSize is bufio.Scanner's starting token buffer for
// readSSE. It grows on demand up to sseScannerMaxLineSize.
const sseScannerInitialBufSize = 64 * 1024
// sseScannerMaxLineSize caps a single text/event-stream field line at
// 4MiB. Upstream LLM SSE events can carry large data lines — a
// base64-encoded image, for instance — so the cap is generous, but a line
// beyond it makes bufio.Scanner return bufio.ErrTooLong instead of
// growing its buffer without bound.
const sseScannerMaxLineSize = 4 * 1024 * 1024
// sseMaxEventBytes caps the TOTAL data bytes readSSE will accumulate for
// ONE event, across however many "data:" lines that event spans.
//
// Security audit run-1: sseScannerMaxLineSize above bounds a single LINE,
// not an event, and readSSE's accumulator resets only on a blank line or
// at EOF — so an upstream that emits many short "data:" lines and never
// terminates the record grows dataLines without any bound at all, and
// peak residency at dispatch is roughly three times that (the joined
// string, its []byte copy, and the slice, all live at once). The per-line
// cap never fires for that shape, because no individual line is large.
// Under deployment form A the heap being grown is the shared Traefik
// ingress process's, so this is not self-inflicted: it is every other
// tenant and every unrelated route on that instance.
//
// The value is generous — an event legitimately carrying a base64 image
// stays far below it — and is a backstop against unbounded growth, not a
// tuning knob.
const sseMaxEventBytes = 8 * 1024 * 1024
// errSSEEventTooLarge is returned by readSSE when one event's accumulated
// "data:" payload exceeds sseMaxEventBytes. It mirrors how the per-line
// cap surfaces (bufio.ErrTooLong is returned as-is): the caller's stream
// forwarding stops and the error propagates, rather than the gateway
// continuing to buffer an event that will never terminate.
var errSSEEventTooLarge = errors.New("llmgateway: sse event data exceeded the maximum accumulated size")
// sseDataPrefix is the "data:" field prefix sseWriter writes before every
// event payload, per the text/event-stream wire format.
const sseDataPrefix = "data: "
// sseBOM is the UTF-8 byte-order mark. Some proxies still add one at the
// start of a stream. Left in place, it attaches to the first field name.
// "data:" becomes "\ufeffdata:", and readSSE drops the first event as an
// unknown field. readSSE removes one leading BOM from the first line.
const sseBOM = "\ufeff"
// sseEvent is one parsed text/event-stream event: its "event:" field
// (empty when the stream omits it) and its "data:" field, with multi-line
// data joined by "\n" per the SSE spec.
type sseEvent struct {
event string
data []byte
}
// scanSSELines is readSSE's bufio.Scanner split function (finding 3 fix,
// review-routes.md): it recognizes every line terminator the WHATWG
// EventSource specification does — CRLF, a lone LF, and a lone CR — where
// bufio.ScanLines (the previous, default split function; in regexp
// notation, `\r?\n`) recognizes only CRLF and a lone LF. A lone "\r"
// previously stayed embedded in a line's own content instead of ending
// it, so an upstream sending CR-only line endings (or a malicious one
// exploiting exactly this gap) parsed as one giant line instead of the
// several the specification requires (https://html.spec.whatwg.org/
// multipage/server-sent-events.html#event-stream-interpretation).
// Structured like bufio.ScanLines itself: find the next terminator, or
// ask for more data:
// - data[i] == '\n': an ordinary (or CRLF-preceded, since IndexAny
// finds whichever of \r/\n comes first — a lone '\r' immediately
// before is handled by its own branch below, not reached here) LF
// terminator.
// - data[i] == '\r' as the LAST currently-buffered byte, with more
// input possibly still to come (!atEOF): request more data first, so
// a CRLF pair split across two Read calls is never misread as a lone
// CR followed by a separate, spurious empty line.
// - data[i] == '\r' followed by '\n': CRLF, one terminator, not two.
// - data[i] == '\r' otherwise: a lone CR, its own terminator.
func scanSSELines(data []byte, atEOF bool) (advance int, token []byte, err error) {
if atEOF && len(data) == 0 {
return 0, nil, nil
}
if i := bytes.IndexAny(data, "\r\n"); i >= 0 {
if data[i] == '\n' {
return i + 1, data[:i], nil
}
if i+1 == len(data) && !atEOF {
return 0, nil, nil
}
if i+1 < len(data) && data[i+1] == '\n' {
return i + 2, data[:i], nil
}
return i + 1, data[:i], nil
}
if atEOF {
return len(data), data, nil
}
return 0, nil, nil
}
// readSSE parses r as a text/event-stream body and calls fn once per
// event. It accumulates "event:" and "data:" field lines. Multiple
// "data:" lines join with "\n". It dispatches fn on a blank line, then
// resets the accumulated fields. Comment lines that start with ":", and
// "id:"/"retry:" fields, are recognized and silently ignored. Line
// endings can be "\n", "\r\n", or a lone "\r" (scanSSELines, above,
// replaces bufio.ScanLines as the scanner's split function so all three
// are recognized — finding 3 fix, review-routes.md). A leading UTF-8
// byte-order mark on the stream's first line is stripped before field
// parsing.
//
// readSSE follows the EventSource specification: it fires fn only when
// the data buffer is not empty. An "event:" field alone, or a lone
// "data:\n\n" line, produces no event.
//
// readSSE stops and returns fn's error the first time fn returns one. On
// a clean EOF it returns nil, and first dispatches one final event if
// fields were pending with no trailing blank line. This EOF flush is a
// deliberate deviation from the specification, which discards an
// unterminated event instead. Callers must treat that final event as
// possibly incomplete, since the upstream connection can drop mid-frame.
// A field line longer than sseScannerMaxLineSize makes the underlying
// scanner fail. That error, bufio.ErrTooLong, is returned as-is.
func readSSE(r io.Reader, fn func(sseEvent) error) error {
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, sseScannerInitialBufSize), sseScannerMaxLineSize)
scanner.Split(scanSSELines)
var (
event string
dataLines []string
hasData bool
dataBytes int
)
firstLine := true
// dispatch fires fn on a blank line, but only when the data buffer is
// not empty. The specification discards an event whose data buffer
// is empty even when a "data:" line was seen, so a lone "data:\n\n"
// line must not reach fn. dispatch always resets the accumulated
// fields, dispatched or not.
dispatch := func() error {
joined := strings.Join(dataLines, "\n")
fire := hasData && joined != ""
ev := sseEvent{event: event, data: []byte(joined)}
// Three separate assignments, not one "event, dataLines, hasData =
// \"\", nil, false" multi-value statement: yaegi v0.16.1 panics
// ("reflect: New(nil)") interpreting a 3-way assignment mixing a
// string, a nil slice, and a bool literal in one statement — the
// same class of bug as resolveAgainst's local-var fix (registry.go),
// just in assignment form instead of a return tuple. The identical
// values assigned one statement at a time do not trigger it.
// Verified empirically under real Traefik (Task 15's integration
// suite, the SSE streaming path); tools/yaegi-check never exercises
// a live streaming response.
event = ""
dataLines = nil
hasData = false
dataBytes = 0
if !fire {
return nil
}
return fn(ev)
}
for scanner.Scan() {
line := scanner.Text()
if firstLine {
firstLine = false
line = strings.TrimPrefix(line, sseBOM)
}
if line == "" {
if err := dispatch(); err != nil {
return err
}
continue
}
if strings.HasPrefix(line, ":") {
continue // comment line, per the SSE spec
}
field, value, _ := strings.Cut(line, ":")
value = strings.TrimPrefix(value, " ")
switch field {
case "event":
event = value
case "data":
// +1 for the "\n" this line will contribute to the joined
// payload. Checked BEFORE appending, so the cap bounds what is
// actually held rather than catching it one line late.
dataBytes += len(value) + 1
if dataBytes > sseMaxEventBytes {
return errSSEEventTooLarge
}
dataLines = append(dataLines, value)
hasData = true
case "id", "retry":
// recognized fields, intentionally ignored
}
}
if err := scanner.Err(); err != nil {
return err
}
return dispatch()
}
// sseWriter writes Server-Sent Events over an http.ResponseWriter,
// flushing after each event so a proxied client sees each chunk as it is
// produced instead of buffered until the response closes.
type sseWriter struct {
w http.ResponseWriter
f http.Flusher
}
// newSSEWriter returns an sseWriter over w, setting the SSE response
// headers before anything is written: Content-Type: text/event-stream,
// Cache-Control: no-cache, and X-Accel-Buffering: no (disables buffering
// in a reverse proxy such as nginx sitting in front of the gateway). w
// need not implement http.Flusher — the Gateway's statusTrackingWriter
// always does, since it delegates — but writeData/writeDone tolerate its
// absence by skipping the flush.
//
// KNOWN LIMITATION under real Traefik (yaegi v0.16.1), confirmed by
// Task 15's integration suite: the w.(http.Flusher) assertion below
// always reports false, because Yaegi wraps any http.ResponseWriter
// argument crossing from Traefik's compiled dispatcher into this
// interpreted package in a synthetic type scoped to exactly the
// http.ResponseWriter method set — Header/Write/WriteHeader — dropping
// any other interface the real writer satisfies. The same holds one
// layer down: statusTrackingWriter.Flush's own w.rw.(http.Flusher)
// check, and even http.NewResponseController(w.rw).Flush(), fail the
// same way, all verified empirically. Net effect: streamed responses
// (chat completions, MCP/A2A proxying via flushWriter below) are
// correct in content but arrive as one batch when the handler returns,
// not incrementally, when this plugin is loaded via localPlugins —
// unlike compiled Go, where this exact code streams correctly (see the
// unit tests). Confirmed as an external, still-open Yaegi/Traefik bug,
// not a defect in this code: traefik/yaegi#1600 and, with the
// "confirmed bug" label from Traefik's own maintainers,
// traefik/traefik#10269. No code-level workaround is known.
func newSSEWriter(w http.ResponseWriter) *sseWriter {
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("X-Accel-Buffering", "no")
f, _ := w.(http.Flusher)
return &sseWriter{w: w, f: f}
}
// splitSSELines splits b on every line terminator scanSSELines (above)
// recognizes on the read side — CRLF, a lone LF, and a lone CR — kept in
// lockstep so this gateway's own OUTPUT can never itself carry the exact
// injection scanSSELines exists to catch on input (finding 3 fix,
// review-routes.md): the terminator consumed by each split is dropped
// from the returned line, never left embedded in it.
func splitSSELines(b []byte) [][]byte {
var lines [][]byte
for {
i := bytes.IndexAny(b, "\r\n")
if i < 0 {
return append(lines, b)
}
lines = append(lines, b[:i])
if b[i] == '\r' && i+1 < len(b) && b[i+1] == '\n' {
i++
}
b = b[i+1:]
}
}
// writeData writes one SSE data event as a single Write call, flushing
// when the underlying writer supports that.
//
// Every line terminator inside b is now ENCODED rather than emitted raw:
// b is split on every terminator scanSSELines recognizes — CRLF, a lone
// LF, and a lone CR (splitSSELines, below; finding 3 fix, review-
// routes.md) — and each resulting line gets its own "data: " prefix,
// which is exactly how the text/event-stream format represents a
// multi-line payload, and is what a conforming client rejoins with "\n"
// on the other side. For single-line b — every caller that passes
// compact JSON, which is still what callers should pass — the bytes
// written are byte-for-byte identical to before.
//
// Security audit run-1: this function previously DOCUMENTED that b must
// not contain a newline and enforced nothing, while readSSE on the other
// side is specified (and unit-tested) to JOIN multi-line data with "\n".
// openaiAdapter.forwardStream relays readSSE's output straight here, so
// an upstream could close the frame early and inject a complete second
// event — carrying its own event:/id:/retry: fields, which this gateway
// otherwise strips — into the client's stream. Encoding the newline
// closes that by construction, with no validation branch to get wrong.
//
// Finding 3 fix (review-routes.md): the ORIGINAL version of this
// encoding split on "\n" alone (bytes.Split(b, []byte{'\n'})), which left
// a raw, embedded "\r" untouched inside whatever "line" it fell in — and
// a spec-conforming EventSource client treats a lone CR as its own line
// terminator, so that raw "\r" let an upstream (relayed here verbatim by
// forwardStream) smuggle an attacker-chosen "event:"/"data:" pair past
// this gateway's own readSSE (which, before its own finding-3 fix,
// parsed CR-only sequences as ordinary line content) and have the
// DOWNSTREAM client parse it as a second, spoofed event — the exact
// injection this function's doc comment above already claimed was
// "closed by construction". splitSSELines closes the other half: no raw
// "\r" of any kind ever reaches the wire from here, regardless of what
// readSSE upstream of this call did or did not already split it on.
func (s *sseWriter) writeData(b []byte) error {
lines := splitSSELines(b)
buf := make([]byte, 0, len(b)+len(lines)*len(sseDataPrefix)+1)
for _, line := range lines {
buf = append(buf, sseDataPrefix...)
buf = append(buf, line...)
buf = append(buf, '\n')
}
buf = append(buf, '\n')
if _, err := s.w.Write(buf); err != nil {
return err
}
if s.f != nil {
s.f.Flush()
}
return nil
}
// writeDone writes the SSE stream's terminal "data: [DONE]\n\n" sentinel
// (the OpenAI streaming convention) and flushes. Write errors are not
// returned — writeDone is the last thing a handler does before returning,
// and the connection is about to close either way.
func (s *sseWriter) writeDone() {
_, _ = s.w.Write([]byte(sseDataPrefix + "[DONE]\n\n"))
if s.f != nil {
s.f.Flush()
}
}
// flushWriter wraps an io.Writer, flushing after every Write. Passthrough
// and MCP proxying use it as the destination for io.Copy, so a streamed
// reverse-proxy body reaches the client incrementally instead of sitting
// in a buffer until the copy finishes.
type flushWriter struct {
w io.Writer
f http.Flusher
}
// newFlushWriter returns a flushWriter wrapping w, asserting once
// whether w implements http.Flusher. Callers that stream a
// reverse-proxy copy build one flushWriter per response, instead of
// repeating the same type assertion at every call site.
func newFlushWriter(w io.Writer) *flushWriter {
f, _ := w.(http.Flusher)
return &flushWriter{w: w, f: f}
}
// Write delegates to the wrapped io.Writer, then flushes when f is
// non-nil.
func (fw *flushWriter) Write(b []byte) (int, error) {
n, err := fw.w.Write(b)
if fw.f != nil {
fw.f.Flush()
}
return n, err
}