Files
traefik-llmgateway/sse.go
T

206 lines
7.0 KiB
Go

package traefikllmgateway
import (
"bufio"
"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
// 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
}
// 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" or "\r\n": bufio.ScanLines, the scanner's split
// function, strips an optional trailing "\r" itself. 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)
var (
event string
dataLines []string
hasData bool
)
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)}
event, dataLines, hasData = "", nil, false
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":
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.
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}
}
// writeData writes one SSE data event, "data: " + b + "\n\n", as a
// single Write call. It flushes when the underlying writer supports
// that. b must not contain a newline. The text/event-stream format reads
// everything up to the first newline as the field value, so a raw
// newline inside b splits the frame into two malformed lines. Callers
// must pass compact JSON, not pretty-printed JSON, as b.
func (s *sseWriter) writeData(b []byte) error {
buf := make([]byte, 0, len(sseDataPrefix)+len(b)+2)
buf = append(buf, sseDataPrefix...)
buf = append(buf, b...)
buf = append(buf, '\n', '\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
}