mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
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>
114 lines
3.5 KiB
Go
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,
|
|
})
|
|
}
|