Files
LocalAI/core/http/middleware/compress.go
mudler's LocalAI [bot] ff299df453 perf(http): gzip responses, cache hashed assets, bound the trace endpoints (#11056)
Three measured HTTP-layer regressions on a live deployment, fixed together
because they all shape the bytes on the wire.

1. No compression. The server sent no Content-Encoding regardless of what
   the client asked for, confirmed with curl straight at 127.0.0.1:8080 so
   it was not an ingress artefact. Adds gzip middleware, on by default and
   configurable via LOCALAI_DISABLE_HTTP_COMPRESSION and
   LOCALAI_HTTP_COMPRESSION_MIN_LENGTH (default 1024 bytes so tiny bodies
   are not wastefully wrapped). Streaming routes are skipped explicitly:
   an SSE Accept header, a WebSocket upgrade, and the completion / SSE /
   log-tail path prefixes, because whether a completion request streams is
   decided by the request body, which the middleware runs too early to see.
   Already-compressed formats (woff2, png, mp4, ...) are skipped too; gzip
   made those marginally larger. Measured over the embedded React build:
   JS+CSS 2815 KB raw to 808 KB gzipped (3.48x).

2. No cache headers on content-hashed assets. Vite hashes the filenames,
   so a given /assets/ URL can never change content, yet they shipped with
   no Cache-Control, ETag or Last-Modified, and the browser re-fetched the
   whole bundle on every navigation with no conditional request available.
   /assets/* now carries public, max-age=31536000, immutable. index.html
   stays no-cache so a deploy is picked up, and the unhashed locale JSONs
   get a short TTL rather than the immutable one.

3. Unbounded trace endpoints. /api/traces returned 21,033,606 bytes in
   4.65s and /api/backend-traces 3,471,682 bytes in 1.50s, and the admin
   UI polls both every few seconds. The ring buffer holds up to 1024
   entries, each embedding full input_text payloads. Both list endpoints
   now take limit / offset / full, default to 50 entries, and strip the
   heavy fields (request and response bodies plus headers for API traces,
   body and data for backend traces) unless full=true. Every trace gets a
   process-lifetime ID and GET /api/traces/{id} and
   /api/backend-traces/{id} serve the full record, which is what the UI
   fetches when a row is expanded. The list body stays a JSON array;
   paging metadata rides in X-Total-Count, X-Trace-Offset and
   X-Trace-Limit. Reproducing the live shape in a test, the polled payload
   goes from 21,131,097 bytes to 7,201 bytes.


Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-22 22:51:25 +02:00

114 lines
3.5 KiB
Go

package middleware
import (
"path/filepath"
"slices"
"strings"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
// DefaultCompressionMinLength is the response size (in bytes) below which
// gzip is skipped. Anything smaller tends to grow once the ~20 byte gzip
// envelope and the CPU cost on both ends are accounted for.
const DefaultCompressionMinLength = 1024
// streamingPathPrefixes lists routes that either stream incrementally
// (SSE / chunked token deltas) or hand the connection off entirely
// (WebSocket, WebRTC signalling). Buffering those behind a gzip writer
// defeats incremental flushing: the client sits on an empty buffer until
// enough bytes accumulate to fill a deflate block, which reads as a hung
// stream. They are excluded by path because whether a completion request
// streams is decided by the request BODY (`"stream": true`), which the
// compression middleware runs too early to see.
// The unversioned aliases (/chat/completions, /audio/transcriptions, ...) are
// registered alongside the /v1 forms, so both spellings are listed.
var streamingPathPrefixes = []string{
"/v1/chat/completions",
"/chat/completions",
"/v1/completions",
"/completions",
"/v1/engines/",
"/v1/responses",
"/v1/messages",
"/v1/realtime",
"/v1/audio/speech",
"/audio/speech",
"/v1/audio/transcriptions",
"/audio/transcriptions",
"/api/chat",
"/api/generate",
"/api/agent/jobs",
"/api/backend-logs",
"/api/node-backend-logs",
"/ws/",
}
// streamingPathSubstrings catches SSE bridges that carry a variable
// segment before the streaming suffix, e.g. /api/agents/:name/sse.
var streamingPathSubstrings = []string{
"/sse",
"/progress",
"/stream",
"/events",
}
// precompressedExtensions are formats that already carry their own
// compression. Running deflate over them costs CPU on both ends and makes the
// response marginally LARGER (measured: woff2 font files grow by ~60 bytes),
// so they are served as-is.
var precompressedExtensions = []string{
".woff", ".woff2", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif",
".ico", ".mp3", ".mp4", ".webm", ".ogg", ".zip", ".gz", ".br", ".zst",
}
// skipCompression reports whether a request must bypass gzip.
func skipCompression(c echo.Context) bool {
req := c.Request()
// An explicit SSE Accept header is the strongest signal available
// before the handler runs.
if strings.Contains(req.Header.Get("Accept"), "text/event-stream") {
return true
}
// WebSocket upgrades never carry a compressible body.
if strings.EqualFold(req.Header.Get("Upgrade"), "websocket") {
return true
}
path := req.URL.Path
for _, p := range streamingPathPrefixes {
if strings.HasPrefix(path, p) {
return true
}
}
for _, s := range streamingPathSubstrings {
if strings.Contains(path, s) {
return true
}
}
ext := strings.ToLower(filepath.Ext(path))
if ext != "" && slices.Contains(precompressedExtensions, ext) {
return true
}
return false
}
// Compression returns gzip middleware tuned for LocalAI's traffic mix. The
// React UI ships ~1.8 MB of JS/CSS that compresses to roughly a fifth of
// that, and the admin JSON endpoints are similarly text-heavy, so the win
// is large; streaming routes are excluded via skipCompression.
//
// minLength <= 0 falls back to DefaultCompressionMinLength.
func Compression(minLength int) echo.MiddlewareFunc {
if minLength <= 0 {
minLength = DefaultCompressionMinLength
}
return middleware.GzipWithConfig(middleware.GzipConfig{
Skipper: skipCompression,
MinLength: minLength,
})
}