diff --git a/core/cli/run.go b/core/cli/run.go
index 57689e792..77a9f23e6 100644
--- a/core/cli/run.go
+++ b/core/cli/run.go
@@ -69,6 +69,8 @@ type RunCMD struct {
CORS bool `env:"LOCALAI_CORS,CORS" help:"" group:"api"`
CORSAllowOrigins string `env:"LOCALAI_CORS_ALLOW_ORIGINS,CORS_ALLOW_ORIGINS" group:"api"`
DisableCSRF bool `env:"LOCALAI_DISABLE_CSRF" help:"Disable CSRF middleware (enabled by default)" group:"api"`
+ DisableHTTPCompression bool `env:"LOCALAI_DISABLE_HTTP_COMPRESSION" default:"false" help:"Disable gzip compression of HTTP responses (enabled by default). Streaming endpoints are never compressed" group:"api"`
+ HTTPCompressionMinLength int `env:"LOCALAI_HTTP_COMPRESSION_MIN_LENGTH" default:"1024" help:"Minimum response size in bytes before gzip compression is applied" group:"api"`
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
APIKeys []string `env:"LOCALAI_API_KEY,API_KEY" help:"List of API Keys to enable API authentication. When this is set, all the requests must be authenticated with one of these API keys" group:"api"`
DisableWebUI bool `env:"LOCALAI_DISABLE_WEBUI,DISABLE_WEBUI" default:"false" help:"Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface" group:"api"`
@@ -279,6 +281,8 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
config.WithCors(r.CORS),
config.WithCorsAllowOrigins(r.CORSAllowOrigins),
config.WithDisableCSRF(r.DisableCSRF),
+ config.WithDisableHTTPCompression(r.DisableHTTPCompression),
+ config.WithHTTPCompressionMinLength(r.HTTPCompressionMinLength),
config.WithThreads(r.Threads),
config.WithUploadLimitMB(r.UploadLimit),
config.WithApiKeys(r.APIKeys),
diff --git a/core/config/application_config.go b/core/config/application_config.go
index 194a9183a..7ea81b2b4 100644
--- a/core/config/application_config.go
+++ b/core/config/application_config.go
@@ -50,7 +50,14 @@ type ApplicationConfig struct {
DynamicConfigsDirPollInterval time.Duration
CORS bool
DisableCSRF bool
- PreloadJSONModels string
+ // DisableHTTPCompression turns off the gzip response middleware. Gzip is
+ // on by default because the React UI bundle and the admin JSON APIs are
+ // text-heavy; turn it off only when a fronting proxy already compresses.
+ DisableHTTPCompression bool
+ // HTTPCompressionMinLength is the response-size floor (bytes) below which
+ // gzip is skipped. 0 keeps middleware.DefaultCompressionMinLength.
+ HTTPCompressionMinLength int
+ PreloadJSONModels string
PreloadModelsFromPath string
CORSAllowOrigins string
ApiKeys []string
@@ -382,6 +389,18 @@ func WithDisableCSRF(b bool) AppOption {
}
}
+func WithDisableHTTPCompression(b bool) AppOption {
+ return func(o *ApplicationConfig) {
+ o.DisableHTTPCompression = b
+ }
+}
+
+func WithHTTPCompressionMinLength(n int) AppOption {
+ return func(o *ApplicationConfig) {
+ o.HTTPCompressionMinLength = n
+ }
+}
+
func WithP2PToken(s string) AppOption {
return func(o *ApplicationConfig) {
o.P2PToken = s
diff --git a/core/http/app.go b/core/http/app.go
index 4d969b8fd..bee47013b 100644
--- a/core/http/app.go
+++ b/core/http/app.go
@@ -49,6 +49,12 @@ var reactUI embed.FS
var quietPaths = []string{"/api/operations", "/api/resources", "/healthz", "/readyz"}
+// immutableAssetCacheControl is the Cache-Control served for content-hashed
+// build output. The filename changes whenever the content does, so a one-year
+// TTL plus `immutable` is safe and removes both the re-download and the
+// conditional revalidation round-trip.
+const immutableAssetCacheControl = "public, max-age=31536000, immutable"
+
// applyModelLoadCooldown maps a ModelLoadCooldownError anywhere in err's chain
// to HTTP 503 with a Retry-After header (whole seconds, floor 1), so a client
// polling a model whose load recently failed backs off instead of triggering a
@@ -202,6 +208,13 @@ func API(application *application.Application) (*echo.Echo, error) {
// errors — picks them up.
e.Use(httpMiddleware.SecurityHeaders())
+ // Gzip responses. Registered before the tracing and handler middlewares so
+ // the response writer it installs sits underneath them: the trace buffer
+ // keeps capturing plaintext while the wire carries the compressed bytes.
+ if !application.ApplicationConfig().DisableHTTPCompression {
+ e.Use(httpMiddleware.Compression(application.ApplicationConfig().HTTPCompressionMinLength))
+ }
+
// Custom logger middleware using xlog
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
@@ -512,6 +525,10 @@ func API(application *application.Application) (*echo.Echo, error) {
if err != nil {
return c.String(http.StatusNotFound, "React UI not built")
}
+ // index.html names the content-hashed bundles, so it must never
+ // be cached: a stale copy pins the browser to the previous
+ // deploy's assets.
+ c.Response().Header().Set("Cache-Control", "no-cache")
// Inject for reverse-proxy support; baseURL comes
// from attacker-controllable Host / X-Forwarded-Host headers.
baseURL := httpMiddleware.BaseURL(c)
@@ -573,8 +590,10 @@ func API(application *application.Application) (*echo.Echo, error) {
})
// Serve React static assets (JS, CSS, etc.) and i18n locale JSONs
- // from the embedded React build.
- serveReactSubdir := func(subdir string) echo.HandlerFunc {
+ // from the embedded React build. cacheControl is stamped on every
+ // hit so the browser can reuse the bytes instead of re-fetching
+ // ~1.8 MB of bundle on each navigation.
+ serveReactSubdir := func(subdir, cacheControl string) echo.HandlerFunc {
return func(c echo.Context) error {
p := subdir + "/" + c.Param("*")
f, err := reactFS.Open(p)
@@ -586,14 +605,21 @@ func API(application *application.Application) (*echo.Echo, error) {
if contentType == "" {
contentType = echo.MIMEOctetStream
}
+ if cacheControl != "" {
+ c.Response().Header().Set("Cache-Control", cacheControl)
+ }
return c.Stream(http.StatusOK, contentType, f)
}
}
return echo.NewHTTPError(http.StatusNotFound)
}
}
- e.GET("/assets/*", serveReactSubdir("assets"))
- e.GET("/locales/*", serveReactSubdir("locales"))
+ // Vite content-hashes everything under /assets (Manage-DrwQK63f.js),
+ // so a given URL can never change content: cache it for a year and
+ // skip revalidation entirely. Locale JSONs keep stable names, so
+ // they only get a short TTL.
+ e.GET("/assets/*", serveReactSubdir("assets", immutableAssetCacheControl))
+ e.GET("/locales/*", serveReactSubdir("locales", "public, max-age=300"))
}
}
routes.RegisterJINARoutes(e, requestExtractor, application.ModelConfigLoader(), application.ModelLoader(), application.ApplicationConfig())
diff --git a/core/http/endpoints/localai/traces.go b/core/http/endpoints/localai/traces.go
index 20d0875f7..7e9f1216f 100644
--- a/core/http/endpoints/localai/traces.go
+++ b/core/http/endpoints/localai/traces.go
@@ -1,21 +1,106 @@
package localai
import (
+ "net/http"
+ "strconv"
+
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/middleware"
+ "github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/trace"
)
-// GetAPITracesEndpoint returns all API request/response traces
+// DefaultTraceListLimit bounds the trace list responses. The ring buffer holds
+// up to LOCALAI_TRACING_MAX_ITEMS entries (1024 in a typical deployment) and
+// each one can embed a full request/response payload, so returning the whole
+// buffer made /api/traces a multi-megabyte response that the admin UI
+// re-fetched on every poll. Callers that genuinely want everything can pass
+// limit=0.
+const DefaultTraceListLimit = 50
+
+// MaxTraceListLimit caps an explicit limit so a client cannot ask for an
+// unbounded page by accident.
+const MaxTraceListLimit = 1000
+
+// tracePageParams reads the limit/offset/full query parameters. Invalid values
+// fall back to the bounded defaults rather than erroring, so existing clients
+// keep working.
+func tracePageParams(c echo.Context) (offset, limit int, full bool) {
+ limit = DefaultTraceListLimit
+ if raw := c.QueryParam("limit"); raw != "" {
+ if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
+ limit = n
+ }
+ }
+ if limit > MaxTraceListLimit {
+ limit = MaxTraceListLimit
+ }
+ if raw := c.QueryParam("offset"); raw != "" {
+ if n, err := strconv.Atoi(raw); err == nil && n > 0 {
+ offset = n
+ }
+ }
+ if raw := c.QueryParam("full"); raw != "" {
+ full, _ = strconv.ParseBool(raw)
+ }
+ return offset, limit, full
+}
+
+// setTracePageHeaders publishes the paging metadata out-of-band so the
+// response body stays a plain JSON array for existing consumers.
+func setTracePageHeaders(c echo.Context, total, offset, limit int) {
+ h := c.Response().Header()
+ h.Set("X-Total-Count", strconv.Itoa(total))
+ h.Set("X-Trace-Offset", strconv.Itoa(offset))
+ h.Set("X-Trace-Limit", strconv.Itoa(limit))
+}
+
+func traceNotFound(c echo.Context) error {
+ return c.JSON(http.StatusNotFound, schema.ErrorResponse{
+ Error: &schema.APIError{Message: "trace not found", Code: http.StatusNotFound},
+ })
+}
+
+// GetAPITracesEndpoint returns a bounded page of API request/response traces
// @Summary List API request/response traces
-// @Description Returns captured API exchange traces (request/response pairs) in reverse chronological order
+// @Description Returns a bounded, newest-first page of captured API exchange traces. Request and response bodies plus headers are omitted unless full=true; fetch them per-trace from /api/traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.
// @Tags monitoring
// @Produce json
+// @Param limit query int false "Maximum entries to return (default 50, max 1000, 0 for all)"
+// @Param offset query int false "Number of entries to skip (default 0)"
+// @Param full query bool false "Include request/response bodies and headers (default false)"
// @Success 200 {object} map[string]any "Traced API exchanges"
// @Router /api/traces [get]
func GetAPITracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
- return c.JSON(200, middleware.GetTraces())
+ offset, limit, full := tracePageParams(c)
+ page, total := middleware.GetTracesPage(offset, limit)
+ if !full {
+ for i := range page {
+ page[i] = middleware.SummarizeExchange(page[i])
+ }
+ }
+ setTracePageHeaders(c, total, offset, limit)
+ return c.JSON(http.StatusOK, page)
+ }
+}
+
+// GetAPITraceEndpoint returns a single API trace with its full payload
+// @Summary Get one API trace
+// @Description Returns a single captured API exchange, including the request and response bodies omitted from the list response
+// @Tags monitoring
+// @Produce json
+// @Param id path string true "Trace ID"
+// @Success 200 {object} map[string]any "Traced API exchange"
+// @Failure 404 {object} schema.ErrorResponse "Trace not found"
+// @Router /api/traces/{id} [get]
+func GetAPITraceEndpoint() echo.HandlerFunc {
+ return func(c echo.Context) error {
+ exchange, ok := middleware.GetTrace(c.Param("id"))
+ if !ok {
+ return traceNotFound(c)
+ }
+ return c.JSON(http.StatusOK, exchange)
}
}
@@ -28,20 +113,50 @@ func GetAPITracesEndpoint() echo.HandlerFunc {
func ClearAPITracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
middleware.ClearTraces()
- return c.NoContent(204)
+ return c.NoContent(http.StatusNoContent)
}
}
-// GetBackendTracesEndpoint returns all backend operation traces
+// GetBackendTracesEndpoint returns a bounded page of backend operation traces
// @Summary List backend operation traces
-// @Description Returns captured backend traces (LLM calls, embeddings, TTS, etc.) in reverse chronological order
+// @Description Returns a bounded, newest-first page of captured backend traces (LLM calls, embeddings, TTS, etc). The heavy body and data fields are omitted unless full=true; fetch them per-trace from /api/backend-traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.
// @Tags monitoring
// @Produce json
+// @Param limit query int false "Maximum entries to return (default 50, max 1000, 0 for all)"
+// @Param offset query int false "Number of entries to skip (default 0)"
+// @Param full query bool false "Include the body and data payloads (default false)"
// @Success 200 {object} map[string]any "Backend operation traces"
// @Router /api/backend-traces [get]
func GetBackendTracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
- return c.JSON(200, trace.GetBackendTraces())
+ offset, limit, full := tracePageParams(c)
+ page, total := trace.GetBackendTracesPage(offset, limit)
+ if !full {
+ for i := range page {
+ page[i] = trace.SummarizeBackendTrace(page[i])
+ }
+ }
+ setTracePageHeaders(c, total, offset, limit)
+ return c.JSON(http.StatusOK, page)
+ }
+}
+
+// GetBackendTraceEndpoint returns a single backend trace with its full payload
+// @Summary Get one backend operation trace
+// @Description Returns a single captured backend trace, including the body and data payloads omitted from the list response
+// @Tags monitoring
+// @Produce json
+// @Param id path string true "Trace ID"
+// @Success 200 {object} map[string]any "Backend operation trace"
+// @Failure 404 {object} schema.ErrorResponse "Trace not found"
+// @Router /api/backend-traces/{id} [get]
+func GetBackendTraceEndpoint() echo.HandlerFunc {
+ return func(c echo.Context) error {
+ t, ok := trace.GetBackendTrace(c.Param("id"))
+ if !ok {
+ return traceNotFound(c)
+ }
+ return c.JSON(http.StatusOK, t)
}
}
@@ -54,6 +169,6 @@ func GetBackendTracesEndpoint() echo.HandlerFunc {
func ClearBackendTracesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
trace.ClearBackendTraces()
- return c.NoContent(204)
+ return c.NoContent(http.StatusNoContent)
}
}
diff --git a/core/http/endpoints/localai/traces_test.go b/core/http/endpoints/localai/traces_test.go
index b8474b784..040307fd5 100644
--- a/core/http/endpoints/localai/traces_test.go
+++ b/core/http/endpoints/localai/traces_test.go
@@ -1,11 +1,16 @@
package localai_test
import (
+ "encoding/json"
"net/http"
"net/http/httptest"
+ "strconv"
+ "strings"
+ "time"
"github.com/labstack/echo/v4"
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
+ "github.com/mudler/LocalAI/core/trace"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -16,8 +21,10 @@ var _ = Describe("Traces Endpoints", func() {
BeforeEach(func() {
app = echo.New()
app.GET("/api/traces", GetAPITracesEndpoint())
+ app.GET("/api/traces/:id", GetAPITraceEndpoint())
app.POST("/api/traces/clear", ClearAPITracesEndpoint())
app.GET("/api/backend-traces", GetBackendTracesEndpoint())
+ app.GET("/api/backend-traces/:id", GetBackendTraceEndpoint())
app.POST("/api/backend-traces/clear", ClearBackendTracesEndpoint())
})
@@ -52,4 +59,131 @@ var _ = Describe("Traces Endpoints", func() {
Expect(rec.Code).To(Equal(http.StatusNoContent))
})
+
+ It("advertises the paging metadata in response headers", func() {
+ req := httptest.NewRequest(http.MethodGet, "/api/traces", nil)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ Expect(rec.Header().Get("X-Trace-Limit")).To(Equal(strconv.Itoa(DefaultTraceListLimit)))
+ Expect(rec.Header().Get("X-Trace-Offset")).To(Equal("0"))
+ Expect(rec.Header().Get("X-Total-Count")).ToNot(BeEmpty())
+ })
+
+ It("caps an oversized explicit limit", func() {
+ req := httptest.NewRequest(http.MethodGet, "/api/traces?limit=999999", nil)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ Expect(rec.Header().Get("X-Trace-Limit")).To(Equal(strconv.Itoa(MaxTraceListLimit)))
+ })
+
+ It("404s an unknown trace ID", func() {
+ req := httptest.NewRequest(http.MethodGet, "/api/traces/does-not-exist", nil)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+
+ Expect(rec.Code).To(Equal(http.StatusNotFound))
+ })
+})
+
+// The backend trace buffer is a package-level ring buffer, so these specs run
+// in their own Describe with an explicit clear to stay independent.
+var _ = Describe("Backend traces payload bounding", func() {
+ var app *echo.Echo
+
+ // A payload of the shape that made the live /api/backend-traces response
+ // 3.4 MB: every entry carries the full input text.
+ const heavyText = 8192
+
+ BeforeEach(func() {
+ app = echo.New()
+ app.GET("/api/backend-traces", GetBackendTracesEndpoint())
+ app.GET("/api/backend-traces/:id", GetBackendTraceEndpoint())
+
+ trace.ClearBackendTraces()
+ trace.InitBackendTracingIfEnabled(500, 0)
+ base := time.Now()
+ for i := range 200 {
+ trace.RecordBackendTrace(trace.BackendTrace{
+ // Distinct timestamps keep the newest-first ordering (and
+ // therefore the offset paging) deterministic.
+ Timestamp: base.Add(time.Duration(i) * time.Millisecond),
+ Type: trace.BackendTraceLLM,
+ ModelName: "test-model",
+ Summary: "summary " + strconv.Itoa(i),
+ Body: strings.Repeat("b", heavyText),
+ Data: map[string]any{"input_text": strings.Repeat("x", heavyText)},
+ })
+ }
+ // Recording is asynchronous through a channel; wait for the buffer.
+ Eventually(func() int { return len(trace.GetBackendTraces()) }).Should(Equal(200))
+ })
+
+ AfterEach(func() {
+ trace.ClearBackendTraces()
+ })
+
+ get := func(path string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+ return rec
+ }
+
+ It("bounds the default list to DefaultTraceListLimit entries", func() {
+ rec := get("/api/backend-traces")
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ var out []map[string]any
+ Expect(json.Unmarshal(rec.Body.Bytes(), &out)).To(Succeed())
+ Expect(out).To(HaveLen(DefaultTraceListLimit))
+ Expect(rec.Header().Get("X-Total-Count")).To(Equal("200"))
+ })
+
+ It("omits the heavy body and data fields from list entries", func() {
+ rec := get("/api/backend-traces")
+
+ var out []map[string]any
+ Expect(json.Unmarshal(rec.Body.Bytes(), &out)).To(Succeed())
+ Expect(out[0]).ToNot(HaveKey("body"))
+ Expect(out[0]["data"]).To(BeNil())
+ Expect(out[0]["summary"]).ToNot(BeEmpty())
+ Expect(out[0]["id"]).ToNot(BeEmpty())
+ })
+
+ It("shrinks the polled payload by orders of magnitude", func() {
+ bounded := get("/api/backend-traces").Body.Len()
+ unbounded := get("/api/backend-traces?limit=0&full=true").Body.Len()
+
+ Expect(unbounded).To(BeNumerically(">", 200*heavyText))
+ Expect(bounded).To(BeNumerically("<", unbounded/100))
+ })
+
+ It("serves the full record from the per-trace endpoint", func() {
+ var list []map[string]any
+ Expect(json.Unmarshal(get("/api/backend-traces").Body.Bytes(), &list)).To(Succeed())
+ id, _ := list[0]["id"].(string)
+ Expect(id).ToNot(BeEmpty())
+
+ rec := get("/api/backend-traces/" + id)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ var one map[string]any
+ Expect(json.Unmarshal(rec.Body.Bytes(), &one)).To(Succeed())
+ Expect(one["body"]).To(HaveLen(heavyText))
+ data, ok := one["data"].(map[string]any)
+ Expect(ok).To(BeTrue())
+ Expect(data["input_text"]).To(HaveLen(heavyText))
+ })
+
+ It("pages through the buffer with offset", func() {
+ var first, second []map[string]any
+ Expect(json.Unmarshal(get("/api/backend-traces?limit=10").Body.Bytes(), &first)).To(Succeed())
+ Expect(json.Unmarshal(get("/api/backend-traces?limit=10&offset=10").Body.Bytes(), &second)).To(Succeed())
+
+ Expect(first).To(HaveLen(10))
+ Expect(second).To(HaveLen(10))
+ Expect(second[0]["id"]).ToNot(Equal(first[0]["id"]))
+ })
})
diff --git a/core/http/middleware/compress.go b/core/http/middleware/compress.go
new file mode 100644
index 000000000..7fdd59643
--- /dev/null
+++ b/core/http/middleware/compress.go
@@ -0,0 +1,113 @@
+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,
+ })
+}
diff --git a/core/http/middleware/compress_test.go b/core/http/middleware/compress_test.go
new file mode 100644
index 000000000..88ac98ed3
--- /dev/null
+++ b/core/http/middleware/compress_test.go
@@ -0,0 +1,116 @@
+package middleware_test
+
+import (
+ "bytes"
+ "compress/gzip"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+ "github.com/mudler/LocalAI/core/http/middleware"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Compression middleware", func() {
+ var e *echo.Echo
+
+ // A payload comfortably above the minimum-length threshold and highly
+ // repetitive, so a real gzip pass shrinks it dramatically.
+ body := strings.Repeat("localai compresses this json payload. ", 400)
+
+ BeforeEach(func() {
+ e = echo.New()
+ e.Use(middleware.Compression(middleware.DefaultCompressionMinLength))
+ handler := func(c echo.Context) error {
+ return c.String(http.StatusOK, body)
+ }
+ e.GET("/assets/bundle.js", handler)
+ e.GET("/api/traces", handler)
+ e.GET("/v1/chat/completions", handler)
+ e.GET("/api/agents/demo/sse", handler)
+ e.GET("/api/tiny", func(c echo.Context) error {
+ return c.String(http.StatusOK, "ok")
+ })
+ e.GET("/assets/font.woff2", handler)
+ })
+
+ get := func(path string, headers map[string]string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Header.Set("Accept-Encoding", "gzip")
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+ }
+
+ It("gzips a compressible static asset response", func() {
+ rec := get("/assets/bundle.js", nil)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ Expect(rec.Header().Get("Content-Encoding")).To(Equal("gzip"))
+ Expect(rec.Body.Len()).To(BeNumerically("<", len(body)/2))
+
+ zr, err := gzip.NewReader(bytes.NewReader(rec.Body.Bytes()))
+ Expect(err).ToNot(HaveOccurred())
+ decoded, err := io.ReadAll(zr)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(decoded)).To(Equal(body))
+ })
+
+ It("gzips JSON API responses", func() {
+ rec := get("/api/traces", nil)
+
+ Expect(rec.Header().Get("Content-Encoding")).To(Equal("gzip"))
+ Expect(rec.Body.Len()).To(BeNumerically("<", len(body)))
+ })
+
+ It("does not compress streaming completion endpoints", func() {
+ rec := get("/v1/chat/completions", nil)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
+ Expect(rec.Body.String()).To(Equal(body))
+ })
+
+ It("does not compress SSE bridges", func() {
+ rec := get("/api/agents/demo/sse", nil)
+
+ Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
+ Expect(rec.Body.String()).To(Equal(body))
+ })
+
+ It("does not compress a request that asks for an event stream", func() {
+ rec := get("/api/traces", map[string]string{"Accept": "text/event-stream"})
+
+ Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
+ Expect(rec.Body.String()).To(Equal(body))
+ })
+
+ It("does not compress responses below the minimum length", func() {
+ rec := get("/api/tiny", nil)
+
+ Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
+ Expect(rec.Body.String()).To(Equal("ok"))
+ })
+
+ It("does not re-compress formats that are already compressed", func() {
+ rec := get("/assets/font.woff2", nil)
+
+ Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
+ Expect(rec.Body.String()).To(Equal(body))
+ })
+
+ It("leaves the body untouched when the client does not accept gzip", func() {
+ req := httptest.NewRequest(http.MethodGet, "/assets/bundle.js", nil)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ Expect(rec.Header().Get("Content-Encoding")).To(BeEmpty())
+ Expect(rec.Body.String()).To(Equal(body))
+ })
+})
diff --git a/core/http/middleware/trace.go b/core/http/middleware/trace.go
index 92baf18a4..77180677a 100644
--- a/core/http/middleware/trace.go
+++ b/core/http/middleware/trace.go
@@ -8,7 +8,9 @@ import (
"net"
"net/http"
"slices"
+ "strconv"
"sync"
+ "sync/atomic"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
@@ -36,6 +38,10 @@ type APIExchangeResponse struct {
}
type APIExchange struct {
+ // ID identifies this exchange for the lifetime of the process. The list
+ // endpoint returns trimmed entries; clients fetch the full payload back
+ // by ID from /api/traces/:id.
+ ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Duration time.Duration `json:"duration"`
Request APIExchangeRequest `json:"request"`
@@ -55,6 +61,11 @@ var traceBuffer *circularbuffer.Queue[APIExchange]
var mu sync.Mutex
var logChan = make(chan APIExchange, 100)
var tracingMaxItems int
+var traceIDSeq atomic.Uint64
+
+func nextTraceID() string {
+ return strconv.FormatUint(traceIDSeq.Add(1), 10)
+}
var doInitializeTracing = sync.OnceFunc(func() {
maxItems := tracingMaxItems
@@ -225,6 +236,7 @@ func TraceMiddleware(app *application.Application) echo.MiddlewareFunc {
responseBody := make([]byte, resBody.Len())
copy(responseBody, resBody.Bytes())
exchange := APIExchange{
+ ID: nextTraceID(),
Timestamp: startTime,
Duration: time.Since(startTime),
ClientIP: c.RealIP(),
@@ -282,6 +294,58 @@ func GetTraces() []APIExchange {
return traces
}
+// GetTracesPage returns the newest-first window [offset, offset+limit) of the
+// trace buffer together with the total number of buffered exchanges. A limit
+// <= 0 means "no bound" and returns everything from offset onwards.
+func GetTracesPage(offset, limit int) ([]APIExchange, int) {
+ all := GetTraces()
+ return window(all, offset, limit), len(all)
+}
+
+// GetTrace returns the buffered exchange with the given ID.
+func GetTrace(id string) (APIExchange, bool) {
+ for _, t := range GetTraces() {
+ if t.ID == id {
+ return t, true
+ }
+ }
+ return APIExchange{}, false
+}
+
+// SummarizeExchange strips the heavy parts of an exchange: request/response
+// bodies and header maps. What remains is enough to render the trace list
+// (method, path, status, timing, sizes, caller), and the byte counters are
+// preserved so the UI can still say how big the dropped payload was. Callers
+// fetch the full record by ID when a row is expanded.
+//
+// This is what keeps the polling cost bounded: bodies are what made
+// /api/traces a multi-megabyte response on every refresh.
+func SummarizeExchange(e APIExchange) APIExchange {
+ e.Request.Body = nil
+ e.Request.Headers = nil
+ e.Response.Body = nil
+ e.Response.Headers = nil
+ return e
+}
+
+// window slices s to the requested page, clamping out-of-range bounds to an
+// empty result rather than panicking.
+func window[T any](s []T, offset, limit int) []T {
+ if offset < 0 {
+ offset = 0
+ }
+ if offset >= len(s) {
+ return []T{}
+ }
+ s = s[offset:]
+ if limit > 0 && limit < len(s) {
+ s = s[:limit]
+ }
+ out := make([]T, len(s))
+ copy(out, s)
+ return out
+}
+
// ClearTraces clears the in-memory logs
func ClearTraces() {
mu.Lock()
diff --git a/core/http/middleware/trace_pagination_test.go b/core/http/middleware/trace_pagination_test.go
new file mode 100644
index 000000000..24912145e
--- /dev/null
+++ b/core/http/middleware/trace_pagination_test.go
@@ -0,0 +1,145 @@
+package middleware
+
+import (
+ "encoding/json"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/emirpasic/gods/v2/queues/circularbuffer"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// /api/traces used to serialize the entire ring buffer, bodies included: a
+// 1024-entry buffer of chat completions measured 21 MB on a live deployment,
+// re-fetched by the admin UI every few seconds. These specs pin the two
+// properties that make the poll cheap again: the list is bounded, and list
+// entries carry no payload bodies.
+
+func seedTraceBuffer(n, bodyBytes int) {
+ body := []byte(strings.Repeat("x", bodyBytes))
+ mu.Lock()
+ traceBuffer = circularbuffer.New[APIExchange](n)
+ base := time.Now()
+ for i := range n {
+ reqBody := make([]byte, len(body))
+ copy(reqBody, body)
+ resBody := make([]byte, len(body))
+ copy(resBody, body)
+ reqHeaders := http.Header{"Content-Type": {"application/json"}}
+ resHeaders := http.Header{"Content-Type": {"application/json"}}
+ traceBuffer.Enqueue(APIExchange{
+ ID: strconv.Itoa(i),
+ Timestamp: base.Add(time.Duration(i) * time.Millisecond),
+ Request: APIExchangeRequest{
+ Method: "POST", Path: "/v1/chat/completions",
+ Headers: &reqHeaders, Body: &reqBody, BodyBytes: len(reqBody),
+ },
+ Response: APIExchangeResponse{
+ Status: 200, Headers: &resHeaders, Body: &resBody, BodyBytes: len(resBody),
+ },
+ })
+ }
+ mu.Unlock()
+}
+
+var _ = Describe("Trace pagination", func() {
+ AfterEach(func() {
+ mu.Lock()
+ traceBuffer = nil
+ mu.Unlock()
+ })
+
+ It("returns only the requested window and reports the true total", func() {
+ seedTraceBuffer(200, 64)
+
+ page, total := GetTracesPage(0, 50)
+
+ Expect(total).To(Equal(200))
+ Expect(page).To(HaveLen(50))
+ })
+
+ It("walks the buffer with offset", func() {
+ seedTraceBuffer(20, 8)
+
+ first, _ := GetTracesPage(0, 5)
+ second, _ := GetTracesPage(5, 5)
+
+ Expect(first).To(HaveLen(5))
+ Expect(second).To(HaveLen(5))
+ Expect(second[0].ID).ToNot(Equal(first[0].ID))
+ })
+
+ It("clamps an offset past the end to an empty page instead of panicking", func() {
+ seedTraceBuffer(3, 8)
+
+ page, total := GetTracesPage(500, 10)
+
+ Expect(total).To(Equal(3))
+ Expect(page).To(BeEmpty())
+ })
+
+ It("returns everything when the limit is zero", func() {
+ seedTraceBuffer(17, 8)
+
+ page, total := GetTracesPage(0, 0)
+
+ Expect(total).To(Equal(17))
+ Expect(page).To(HaveLen(17))
+ })
+
+ It("looks a trace up by ID with its body intact", func() {
+ seedTraceBuffer(10, 32)
+
+ found, ok := GetTrace("4")
+
+ Expect(ok).To(BeTrue())
+ Expect(found.ID).To(Equal("4"))
+ Expect(found.Response.Body).ToNot(BeNil())
+ Expect(*found.Response.Body).To(HaveLen(32))
+ })
+
+ It("reports a miss for an unknown ID", func() {
+ seedTraceBuffer(2, 8)
+
+ _, ok := GetTrace("nope")
+
+ Expect(ok).To(BeFalse())
+ })
+
+ It("shrinks the serialized payload by dropping bodies and headers", func() {
+ seedTraceBuffer(100, 4096)
+
+ full, _ := GetTracesPage(0, 0)
+ bounded, _ := GetTracesPage(0, 50)
+ for i := range bounded {
+ bounded[i] = SummarizeExchange(bounded[i])
+ }
+
+ fullJSON, err := json.Marshal(full)
+ Expect(err).ToNot(HaveOccurred())
+ boundedJSON, err := json.Marshal(bounded)
+ Expect(err).ToNot(HaveOccurred())
+
+ Expect(len(boundedJSON)).To(BeNumerically("<", len(fullJSON)/50),
+ "a bounded, summarized page must be orders of magnitude smaller than the full dump")
+ })
+
+ It("keeps the size counters after summarizing so the UI can still report payload sizes", func() {
+ seedTraceBuffer(1, 1024)
+
+ page, _ := GetTracesPage(0, 1)
+ summary := SummarizeExchange(page[0])
+
+ Expect(summary.Request.Body).To(BeNil())
+ Expect(summary.Request.Headers).To(BeNil())
+ Expect(summary.Response.Body).To(BeNil())
+ Expect(summary.Response.Headers).To(BeNil())
+ Expect(summary.Request.BodyBytes).To(Equal(1024))
+ Expect(summary.Response.BodyBytes).To(Equal(1024))
+ Expect(summary.Request.Path).To(Equal("/v1/chat/completions"))
+ Expect(summary.Response.Status).To(Equal(200))
+ })
+})
diff --git a/core/http/react-ui/e2e/traces-audio.spec.js b/core/http/react-ui/e2e/traces-audio.spec.js
index 567fd56c2..ba270374c 100644
--- a/core/http/react-ui/e2e/traces-audio.spec.js
+++ b/core/http/react-ui/e2e/traces-audio.spec.js
@@ -51,10 +51,10 @@ function transcriptionTrace(audioWavBase64) {
}
async function openBackendTraceRow(page, traces) {
- await page.route('**/api/traces', (route) => {
+ await page.route('**/api/traces?*', (route) => {
route.fulfill({ contentType: 'application/json', body: JSON.stringify([]) })
})
- await page.route('**/api/backend-traces', (route) => {
+ await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({ contentType: 'application/json', body: JSON.stringify(traces) })
})
await page.goto('/app/traces')
diff --git a/core/http/react-ui/e2e/traces-errors.spec.js b/core/http/react-ui/e2e/traces-errors.spec.js
index 18c82edf3..e80c97865 100644
--- a/core/http/react-ui/e2e/traces-errors.spec.js
+++ b/core/http/react-ui/e2e/traces-errors.spec.js
@@ -3,7 +3,7 @@ import { test, expect } from './coverage-fixtures.js'
test.describe('Traces - Error Display', () => {
test.beforeEach(async ({ page }) => {
// Mock API traces with sample data so the table renders
- await page.route('**/api/traces', (route) => {
+ await page.route('**/api/traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
@@ -16,7 +16,7 @@ test.describe('Traces - Error Display', () => {
})
})
// Mock backend traces with sample data
- await page.route('**/api/backend-traces', (route) => {
+ await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
@@ -59,10 +59,10 @@ test.describe('Traces - Error Display', () => {
// uncovered, dragging UI line coverage below the regression gate.
test.describe('Traces - vector_store backend trace detail', () => {
test.beforeEach(async ({ page }) => {
- await page.route('**/api/traces', (route) => {
+ await page.route('**/api/traces?*', (route) => {
route.fulfill({ contentType: 'application/json', body: '[]' })
})
- await page.route('**/api/backend-traces', (route) => {
+ await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
diff --git a/core/http/react-ui/e2e/traces-metadata.spec.js b/core/http/react-ui/e2e/traces-metadata.spec.js
index 9e8fdbe70..679add382 100644
--- a/core/http/react-ui/e2e/traces-metadata.spec.js
+++ b/core/http/react-ui/e2e/traces-metadata.spec.js
@@ -6,7 +6,7 @@ import { test, expect } from './coverage-fixtures.js'
// can tell who/what issued each request.
test.describe('Traces - API request metadata', () => {
test.beforeEach(async ({ page }) => {
- await page.route('**/api/traces', (route) => {
+ await page.route('**/api/traces?*', (route) => {
route.fulfill({
contentType: 'application/json',
body: JSON.stringify([
@@ -21,7 +21,7 @@ test.describe('Traces - API request metadata', () => {
]),
})
})
- await page.route('**/api/backend-traces', (route) => {
+ await page.route('**/api/backend-traces?*', (route) => {
route.fulfill({ contentType: 'application/json', body: '[]' })
})
await page.goto('/app/traces')
diff --git a/core/http/react-ui/e2e/traces-pagination.spec.js b/core/http/react-ui/e2e/traces-pagination.spec.js
new file mode 100644
index 000000000..c6d7b89a1
--- /dev/null
+++ b/core/http/react-ui/e2e/traces-pagination.spec.js
@@ -0,0 +1,82 @@
+import { test, expect } from './coverage-fixtures.js'
+
+// The trace list endpoints return a bounded page with the heavy request /
+// response bodies stripped; the full record is fetched per trace when a row
+// is expanded. Without this the admin UI polled a multi-megabyte JSON blob
+// every few seconds. These specs pin both halves of the contract from the
+// browser's side: the page asks for a bounded list, and expanding a row
+// issues the per-trace detail request whose payload is what gets rendered.
+
+const LIST_BODY = [
+ {
+ id: '7',
+ request: { method: 'POST', path: '/v1/chat/completions', body: null },
+ response: { status: 200, body: null, body_bytes: 65536 },
+ },
+]
+
+const DETAIL_BODY = {
+ id: '7',
+ request: {
+ method: 'POST',
+ path: '/v1/chat/completions',
+ // "hello from the request body" base64-encoded, matching the wire shape.
+ body: Buffer.from('hello from the request body').toString('base64'),
+ },
+ response: {
+ status: 200,
+ body: Buffer.from('hello from the response body').toString('base64'),
+ body_bytes: 65536,
+ },
+ client_ip: '203.0.113.9',
+}
+
+test.describe('Traces - bounded list and on-demand detail', () => {
+ let listUrls = []
+
+ test.beforeEach(async ({ page }) => {
+ listUrls = []
+ await page.route('**/api/traces?*', (route) => {
+ listUrls.push(route.request().url())
+ route.fulfill({
+ contentType: 'application/json',
+ headers: { 'X-Total-Count': '842' },
+ body: JSON.stringify(LIST_BODY),
+ })
+ })
+ await page.route('**/api/traces/7', (route) => {
+ route.fulfill({ contentType: 'application/json', body: JSON.stringify(DETAIL_BODY) })
+ })
+ await page.route('**/api/backend-traces?*', (route) => {
+ route.fulfill({
+ contentType: 'application/json',
+ headers: { 'X-Total-Count': '0' },
+ body: '[]',
+ })
+ })
+ await page.goto('/app/traces')
+ await expect(page.locator('text=Tracing is')).toBeVisible({ timeout: 10_000 })
+ })
+
+ test('requests a bounded page rather than the whole buffer', async () => {
+ expect(listUrls.length).toBeGreaterThan(0)
+ expect(listUrls[0]).toContain('limit=')
+ expect(listUrls[0]).not.toContain('limit=0')
+ })
+
+ test('reports the server-side total, not the page length', async ({ page }) => {
+ // The tab counter reflects X-Total-Count (842 buffered) even though only
+ // one entry was returned in the page.
+ await expect(page.locator('button', { hasText: 'API Traces' })).toContainText('842')
+ })
+
+ test('fetches the full record when a row is expanded', async ({ page }) => {
+ await page.locator('tr', { hasText: '/v1/chat/completions' }).first().click()
+
+ // The bodies live only in the detail response, so seeing them proves the
+ // per-trace fetch happened and its payload is what gets rendered.
+ await expect(page.locator('text=hello from the request body')).toBeVisible()
+ await expect(page.locator('text=hello from the response body')).toBeVisible()
+ await expect(page.locator('text=203.0.113.9').first()).toBeVisible()
+ })
+})
diff --git a/core/http/react-ui/src/pages/Traces.jsx b/core/http/react-ui/src/pages/Traces.jsx
index 92c39c7cd..aff887d4a 100644
--- a/core/http/react-ui/src/pages/Traces.jsx
+++ b/core/http/react-ui/src/pages/Traces.jsx
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'
import { useOutletContext, useSearchParams } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
-import { tracesApi, settingsApi } from '../utils/api'
+import { tracesApi, settingsApi, DEFAULT_TRACE_PAGE_SIZE } from '../utils/api'
import { formatDateTime } from '../utils/format'
import LoadingSpinner from '../components/LoadingSpinner'
import PageHeader from '../components/PageHeader'
@@ -10,6 +10,10 @@ import Toggle from '../components/Toggle'
import SettingRow from '../components/SettingRow'
import WaveformPlayer from '../components/audio/WaveformPlayer'
+// How many traces the page keeps on screen. The server buffer holds far more;
+// the counters next to the tab labels report the true total.
+const TRACE_PAGE_SIZE = DEFAULT_TRACE_PAGE_SIZE
+
const AUDIO_DATA_KEYS = new Set([
'audio_wav_base64', 'audio_duration_s', 'audio_snippet_s',
'audio_sample_rate', 'audio_samples', 'audio_rms_dbfs',
@@ -373,6 +377,9 @@ export default function Traces() {
const [backendCount, setBackendCount] = useState(0)
const [loading, setLoading] = useState(true)
const [expandedRow, setExpandedRow] = useState(null)
+ // detail holds the full record for the currently expanded row, fetched on
+ // demand from /api/traces/:id (the list response omits the bodies).
+ const [detail, setDetail] = useState(null)
const [sort, setSort] = useState({ key: null, dir: 'asc' })
const [tracingEnabled, setTracingEnabled] = useState(null)
@@ -435,17 +442,18 @@ export default function Traces() {
}
}
+ // Only a bounded page is fetched, and the server strips the request /
+ // response bodies from list entries — the full record is pulled per row on
+ // expand. The unbounded form was a multi-megabyte transfer on every poll.
const fetchTraces = useCallback(async () => {
try {
- const [apiData, backendData] = await Promise.all([
- tracesApi.get(),
- tracesApi.getBackend(),
+ const [apiPage, backendPage] = await Promise.all([
+ tracesApi.get({ limit: TRACE_PAGE_SIZE }),
+ tracesApi.getBackend({ limit: TRACE_PAGE_SIZE }),
])
- const api = Array.isArray(apiData) ? apiData : []
- const backend = Array.isArray(backendData) ? backendData : []
- setApiCount(api.length)
- setBackendCount(backend.length)
- setTraces(activeTab === 'api' ? api : backend)
+ setApiCount(apiPage.total)
+ setBackendCount(backendPage.total)
+ setTraces(activeTab === 'api' ? apiPage.items : backendPage.items)
} catch (err) {
// Tracing disabled is the default state, not an error — the in-page banner covers it.
const disabled = /disabled|not enabled|404|not found/i.test(err?.message || '')
@@ -460,9 +468,31 @@ export default function Traces() {
useEffect(() => {
setLoading(true)
setExpandedRow(null)
+ setDetail(null)
fetchTraces()
}, [fetchTraces])
+ // Expanding a row pulls the full record (bodies, data fields, audio
+ // snippets) that the list response deliberately omits.
+ const toggleRow = useCallback(async (index, row) => {
+ if (expandedRow === index) {
+ setExpandedRow(null)
+ setDetail(null)
+ return
+ }
+ setExpandedRow(index)
+ setDetail(null)
+ if (!row?.id) return
+ try {
+ const full = activeTab === 'api'
+ ? await tracesApi.getOne(row.id)
+ : await tracesApi.getBackendOne(row.id)
+ setDetail(full)
+ } catch {
+ // Fall back to the summary view; the row still renders what it has.
+ }
+ }, [expandedRow, activeTab])
+
// Auto-refresh every 5 seconds
useEffect(() => {
refreshRef.current = setInterval(fetchTraces, 5000)
@@ -475,14 +505,26 @@ export default function Traces() {
else await tracesApi.clearBackend()
setTraces([])
setExpandedRow(null)
+ setDetail(null)
addToast('Traces cleared', 'success')
} catch (err) {
addToast(`Failed to clear: ${err.message}`, 'error')
}
}
- const handleExport = () => {
- const blob = new Blob([JSON.stringify(traces, null, 2)], { type: 'application/json' })
+ // Export asks for the full payloads explicitly — the on-screen list only
+ // holds summaries, and an export without bodies would be useless.
+ const handleExport = async () => {
+ let rows = traces
+ try {
+ const page = activeTab === 'api'
+ ? await tracesApi.get({ limit: TRACE_PAGE_SIZE, full: true })
+ : await tracesApi.getBackend({ limit: TRACE_PAGE_SIZE, full: true })
+ rows = page.items
+ } catch (err) {
+ addToast(`Exporting summaries only: ${err.message}`, 'error')
+ }
+ const blob = new Blob([JSON.stringify(rows, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
@@ -492,7 +534,7 @@ export default function Traces() {
}
// Reset sort + expansion when switching trace tabs (columns differ).
- useEffect(() => { setSort({ key: null, dir: 'asc' }); setExpandedRow(null) }, [activeTab])
+ useEffect(() => { setSort({ key: null, dir: 'asc' }); setExpandedRow(null); setDetail(null) }, [activeTab])
const sortedTraces = sort.key && TRACE_SORT[sort.key]
? [...traces].sort((a, b) => sort.dir === 'asc' ? TRACE_SORT[sort.key](a, b) : TRACE_SORT[sort.key](b, a))
@@ -643,7 +685,7 @@ export default function Traces() {
{sortedTraces.map((trace, i) => (
- setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
+
toggleRow(i, trace)} style={{ cursor: 'pointer' }}>
|
{trace.request?.method || '-'} |
{trace.request?.path || '-'} |
@@ -658,7 +700,7 @@ export default function Traces() {
{expandedRow === i && (
|
-
+
|
)}
@@ -682,7 +724,7 @@ export default function Traces() {
{sortedTraces.map((trace, i) => (
- setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
+
toggleRow(i, trace)} style={{ cursor: 'pointer' }}>
|
{trace.type || '-'} |
{formatDateTime(trace.timestamp)} |
@@ -700,7 +742,7 @@ export default function Traces() {
{expandedRow === i && (
|
-
+
|
)}
diff --git a/core/http/react-ui/src/utils/api.js b/core/http/react-ui/src/utils/api.js
index e2b76ba32..8a5de08e0 100644
--- a/core/http/react-ui/src/utils/api.js
+++ b/core/http/react-ui/src/utils/api.js
@@ -210,10 +210,29 @@ export const backendLogsApi = {
}
// Traces API
+//
+// The list endpoints return a bounded page with the heavy request/response
+// bodies stripped; the total buffered count arrives in X-Total-Count and the
+// full record is fetched per trace when a row is expanded. Polling the
+// unbounded form used to move tens of megabytes every few seconds.
+export const DEFAULT_TRACE_PAGE_SIZE = 50
+
+async function fetchTracePage(endpoint, { limit = DEFAULT_TRACE_PAGE_SIZE, offset = 0, full = false } = {}) {
+ const response = await fetch(buildUrl(endpoint, { limit, offset, full: full ? 'true' : undefined }), {
+ headers: { 'Content-Type': 'application/json' },
+ })
+ const items = await handleResponse(response)
+ const list = Array.isArray(items) ? items : []
+ const total = parseInt(response.headers.get('X-Total-Count') || '', 10)
+ return { items: list, total: Number.isNaN(total) ? list.length : total }
+}
+
export const tracesApi = {
- get: () => fetchJSON(API_CONFIG.endpoints.traces),
+ get: (opts) => fetchTracePage(API_CONFIG.endpoints.traces, opts),
+ getOne: (id) => fetchJSON(API_CONFIG.endpoints.trace(id)),
clear: () => postJSON(API_CONFIG.endpoints.clearTraces, {}),
- getBackend: () => fetchJSON(API_CONFIG.endpoints.backendTraces),
+ getBackend: (opts) => fetchTracePage(API_CONFIG.endpoints.backendTraces, opts),
+ getBackendOne: (id) => fetchJSON(API_CONFIG.endpoints.backendTrace(id)),
clearBackend: () => postJSON(API_CONFIG.endpoints.clearBackendTraces, {}),
}
diff --git a/core/http/react-ui/src/utils/config.js b/core/http/react-ui/src/utils/config.js
index 3ddf19eb7..ed4776cb8 100644
--- a/core/http/react-ui/src/utils/config.js
+++ b/core/http/react-ui/src/utils/config.js
@@ -37,8 +37,10 @@ export const API_CONFIG = {
// Traces
traces: '/api/traces',
+ trace: (id) => `/api/traces/${encodeURIComponent(id)}`,
clearTraces: '/api/traces/clear',
backendTraces: '/api/backend-traces',
+ backendTrace: (id) => `/api/backend-traces/${encodeURIComponent(id)}`,
clearBackendTraces: '/api/backend-traces/clear',
// Backend Logs
diff --git a/core/http/routes/localai.go b/core/http/routes/localai.go
index d0e44f8e1..4a83e9009 100644
--- a/core/http/routes/localai.go
+++ b/core/http/routes/localai.go
@@ -224,8 +224,10 @@ func RegisterLocalAIRoutes(router *echo.Echo,
// Traces and backend logs (monitoring)
router.GET("/api/traces", localai.GetAPITracesEndpoint(), adminMiddleware)
+ router.GET("/api/traces/:id", localai.GetAPITraceEndpoint(), adminMiddleware)
router.POST("/api/traces/clear", localai.ClearAPITracesEndpoint(), adminMiddleware)
router.GET("/api/backend-traces", localai.GetBackendTracesEndpoint(), adminMiddleware)
+ router.GET("/api/backend-traces/:id", localai.GetBackendTraceEndpoint(), adminMiddleware)
router.POST("/api/backend-traces/clear", localai.ClearBackendTracesEndpoint(), adminMiddleware)
// Backend logs — standalone only (distributed mode uses node-proxied routes)
if !appConfig.Distributed.Enabled {
@@ -260,8 +262,10 @@ func RegisterLocalAIRoutes(router *echo.Echo,
"system": "/system",
"version": "/version",
"traces": "/api/traces",
+ "trace": "/api/traces/:id",
"traces_clear": "/api/traces/clear",
"backend_traces": "/api/backend-traces",
+ "backend_trace": "/api/backend-traces/:id",
"backend_traces_clear": "/api/backend-traces/clear",
}
if !appConfig.Distributed.Enabled {
diff --git a/core/http/static_cache_test.go b/core/http/static_cache_test.go
new file mode 100644
index 000000000..75c124d2b
--- /dev/null
+++ b/core/http/static_cache_test.go
@@ -0,0 +1,146 @@
+package http_test
+
+import (
+ "context"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/labstack/echo/v4"
+ "github.com/mudler/LocalAI/core/application"
+ "github.com/mudler/LocalAI/core/config"
+ . "github.com/mudler/LocalAI/core/http"
+ "github.com/mudler/LocalAI/pkg/system"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+// Vite content-hashes the React bundle filenames, so a given /assets/ URL can
+// never change content. Serving those without Cache-Control meant the browser
+// re-downloaded the whole ~1.8 MB bundle on every navigation, with not even a
+// conditional request available to turn it into a 304. index.html carries the
+// hashed filenames, so it must stay uncached or a deploy would never be
+// picked up.
+var _ = Describe("Static asset caching", Ordered, func() {
+ var (
+ app *echo.Echo
+ tmpdir string
+ cancel context.CancelFunc
+ asset string
+ )
+
+ BeforeAll(func() {
+ var err error
+ tmpdir, err = os.MkdirTemp("", "static-cache-")
+ Expect(err).ToNot(HaveOccurred())
+
+ modelDir := filepath.Join(tmpdir, "models")
+ Expect(os.Mkdir(modelDir, 0750)).To(Succeed())
+ bDir := filepath.Join(tmpdir, "backends")
+ Expect(os.Mkdir(bDir, 0750)).To(Succeed())
+
+ var c context.Context
+ c, cancel = context.WithCancel(context.Background())
+
+ systemState, err := system.GetSystemState(
+ system.WithBackendPath(bDir),
+ system.WithModelPath(modelDir),
+ )
+ Expect(err).ToNot(HaveOccurred())
+
+ appInst, err := application.New(
+ config.WithContext(c),
+ config.WithSystemState(systemState),
+ )
+ Expect(err).ToNot(HaveOccurred())
+
+ app, err = API(appInst)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Pick a real filename out of the embedded build so the spec exercises
+ // the same handler path a browser would hit.
+ asset = firstEmbeddedAsset(app)
+ Expect(asset).ToNot(BeEmpty(), "the embedded React build must contain at least one asset")
+ })
+
+ AfterAll(func() {
+ cancel()
+ Expect(os.RemoveAll(tmpdir)).To(Succeed())
+ })
+
+ do := func(path string, headers map[string]string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+ return rec
+ }
+
+ It("serves content-hashed assets as immutable for a year", func() {
+ rec := do("/assets/"+asset, nil)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ cc := rec.Header().Get("Cache-Control")
+ Expect(cc).To(ContainSubstring("public"))
+ Expect(cc).To(ContainSubstring("max-age=31536000"))
+ Expect(cc).To(ContainSubstring("immutable"))
+ })
+
+ It("keeps index.html uncached so a deploy is picked up", func() {
+ rec := do("/app", nil)
+
+ Expect(rec.Code).To(Equal(http.StatusOK))
+ Expect(rec.Header().Get("Cache-Control")).To(Equal("no-cache"))
+ })
+
+ It("does not mark the unhashed locale files immutable", func() {
+ rec := do("/locales/en/common.json", nil)
+
+ if rec.Code == http.StatusOK {
+ Expect(rec.Header().Get("Cache-Control")).ToNot(ContainSubstring("immutable"))
+ }
+ })
+
+ It("gzips the asset when the client accepts it", func() {
+ plain := do("/assets/"+asset, nil)
+ gzipped := do("/assets/"+asset, map[string]string{"Accept-Encoding": "gzip"})
+
+ Expect(gzipped.Code).To(Equal(http.StatusOK))
+ // Small assets fall below the compression threshold; only assert the
+ // shrink when the middleware actually engaged.
+ if gzipped.Header().Get("Content-Encoding") == "gzip" {
+ Expect(gzipped.Body.Len()).To(BeNumerically("<", plain.Body.Len()))
+ }
+ Expect(plain.Header().Get("Content-Encoding")).To(BeEmpty())
+ })
+})
+
+// firstEmbeddedAsset asks the running app for the asset listing indirectly:
+// the SPA index references its own bundles, so parsing it yields a filename
+// that is guaranteed to exist in the embedded build.
+func firstEmbeddedAsset(app *echo.Echo) string {
+ req := httptest.NewRequest(http.MethodGet, "/app", nil)
+ rec := httptest.NewRecorder()
+ app.ServeHTTP(rec, req)
+ body, err := io.ReadAll(rec.Body)
+ if err != nil {
+ return ""
+ }
+ const marker = "/assets/"
+ idx := strings.Index(string(body), marker)
+ if idx < 0 {
+ return ""
+ }
+ rest := string(body)[idx+len(marker):]
+ end := strings.IndexAny(rest, `"'`)
+ if end < 0 {
+ return ""
+ }
+ return rest[:end]
+}
diff --git a/core/trace/backend_trace.go b/core/trace/backend_trace.go
index 8d8ef6d10..051b471bf 100644
--- a/core/trace/backend_trace.go
+++ b/core/trace/backend_trace.go
@@ -6,7 +6,9 @@ import (
"maps"
"math"
"slices"
+ "strconv"
"sync"
+ "sync/atomic"
"time"
"github.com/emirpasic/gods/v2/queues/circularbuffer"
@@ -42,6 +44,10 @@ const (
)
type BackendTrace struct {
+ // ID identifies this trace for the lifetime of the process, so the list
+ // endpoint can return trimmed entries and clients can fetch the full
+ // record back from /api/backend-traces/:id.
+ ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
Duration time.Duration `json:"duration"`
Type BackendTraceType `json:"type"`
@@ -68,6 +74,7 @@ var (
backendMu sync.Mutex
backendLogChan = make(chan *BackendTrace, 100)
backendInitOnce sync.Once
+ backendTraceIDSeq atomic.Uint64
)
// backendMaxBodyBytes caps each captured string value in a BackendTrace.Data
@@ -126,6 +133,9 @@ func RecordBackendTrace(t BackendTrace) {
if t.Data != nil {
t.Data = sanitizeData(t.Data, maxBody)
}
+ if t.ID == "" {
+ t.ID = strconv.FormatUint(backendTraceIDSeq.Add(1), 10)
+ }
select {
case backendLogChan <- &t:
default:
@@ -241,6 +251,48 @@ func GetBackendTraces() []BackendTrace {
return traces
}
+// GetBackendTracesPage returns the newest-first window
+// [offset, offset+limit) of the backend trace buffer plus the total number
+// of buffered traces. A limit <= 0 means "no bound".
+func GetBackendTracesPage(offset, limit int) ([]BackendTrace, int) {
+ all := GetBackendTraces()
+ total := len(all)
+ if offset < 0 {
+ offset = 0
+ }
+ if offset >= total {
+ return []BackendTrace{}, total
+ }
+ page := all[offset:]
+ if limit > 0 && limit < len(page) {
+ page = page[:limit]
+ }
+ out := make([]BackendTrace, len(page))
+ copy(out, page)
+ return out, total
+}
+
+// GetBackendTrace returns the buffered trace with the given ID.
+func GetBackendTrace(id string) (BackendTrace, bool) {
+ for _, t := range GetBackendTraces() {
+ if t.ID == id {
+ return t, true
+ }
+ }
+ return BackendTrace{}, false
+}
+
+// SummarizeBackendTrace drops the heavy fields (the full request Body and the
+// Data map, which carries things like base64 audio snippets and complete
+// input_text payloads) while keeping everything the trace list renders. The
+// full record stays reachable by ID. Without this the list response grew to
+// tens of megabytes and the UI re-fetched it every few seconds.
+func SummarizeBackendTrace(t BackendTrace) BackendTrace {
+ t.Body = ""
+ t.Data = nil
+ return t
+}
+
func ClearBackendTraces() {
backendMu.Lock()
if backendTraceBuffer != nil {
diff --git a/docs/content/features/authentication.md b/docs/content/features/authentication.md
index 704d97dff..39530d28c 100644
--- a/docs/content/features/authentication.md
+++ b/docs/content/features/authentication.md
@@ -162,8 +162,8 @@ When authentication is enabled, the following endpoints require admin role:
- `GET /backends`, `GET /backends/available`, `GET /backends/galleries`
**System & Monitoring:**
-- `GET /api/traces`, `POST /api/traces/clear`
-- `GET /api/backend-traces`, `POST /api/backend-traces/clear`
+- `GET /api/traces`, `GET /api/traces/{id}`, `POST /api/traces/clear`
+- `GET /api/backend-traces`, `GET /api/backend-traces/{id}`, `POST /api/backend-traces/clear`
- `GET /api/backend-logs/*`, `POST /api/backend-logs/*/clear`
- `GET /api/resources`, `GET /api/settings`, `POST /api/settings`
- `GET /system`, `GET /backend/monitor`, `POST /backend/shutdown`, `POST /backend/load`
diff --git a/docs/content/reference/cli-reference.md b/docs/content/reference/cli-reference.md
index 67e413072..d0b82ed6c 100644
--- a/docs/content/reference/cli-reference.md
+++ b/docs/content/reference/cli-reference.md
@@ -83,6 +83,8 @@ For more information on VRAM management, see [VRAM and Memory Management]({{%rel
| `--cors` | `false` | Enable CORS (Cross-Origin Resource Sharing) | `$LOCALAI_CORS`, `$CORS` |
| `--cors-allow-origins` | | Comma-separated list of allowed CORS origins | `$LOCALAI_CORS_ALLOW_ORIGINS`, `$CORS_ALLOW_ORIGINS` |
| `--csrf` | `false` | Enable Fiber CSRF middleware | `$LOCALAI_CSRF` |
+| `--disable-http-compression` | `false` | Disable gzip compression of HTTP responses. Compression is enabled by default; streaming endpoints (streaming chat completions, SSE bridges, WebSocket upgrades) and already-compressed formats are never compressed | `$LOCALAI_DISABLE_HTTP_COMPRESSION` |
+| `--http-compression-min-length` | `1024` | Minimum response size in bytes before gzip compression is applied. Smaller responses are sent as-is because the gzip envelope would outweigh the saving | `$LOCALAI_HTTP_COMPRESSION_MIN_LENGTH` |
| `--upload-limit` | `15` | Default upload-limit in MB | `$LOCALAI_UPLOAD_LIMIT`, `$UPLOAD_LIMIT` |
| `--api-keys` | | List of API Keys to enable API authentication. When this is set, all requests must be authenticated with one of these API keys | `$LOCALAI_API_KEY`, `$API_KEY` |
| `--disable-webui` | `false` | Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface | `$LOCALAI_DISABLE_WEBUI`, `$DISABLE_WEBUI` |
diff --git a/swagger/docs.go b/swagger/docs.go
index d557cef3c..5dd3705b9 100644
--- a/swagger/docs.go
+++ b/swagger/docs.go
@@ -599,7 +599,7 @@ const docTemplate = `{
},
"/api/backend-traces": {
"get": {
- "description": "Returns captured backend traces (LLM calls, embeddings, TTS, etc.) in reverse chronological order",
+ "description": "Returns a bounded, newest-first page of captured backend traces (LLM calls, embeddings, TTS, etc). The heavy body and data fields are omitted unless full=true; fetch them per-trace from /api/backend-traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.",
"produces": [
"application/json"
],
@@ -607,6 +607,26 @@ const docTemplate = `{
"monitoring"
],
"summary": "List backend operation traces",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Maximum entries to return (default 50, max 1000, 0 for all)",
+ "name": "limit",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Number of entries to skip (default 0)",
+ "name": "offset",
+ "in": "query"
+ },
+ {
+ "type": "boolean",
+ "description": "Include the body and data payloads (default false)",
+ "name": "full",
+ "in": "query"
+ }
+ ],
"responses": {
"200": {
"description": "Backend operation traces",
@@ -632,6 +652,42 @@ const docTemplate = `{
}
}
},
+ "/api/backend-traces/{id}": {
+ "get": {
+ "description": "Returns a single captured backend trace, including the body and data payloads omitted from the list response",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "monitoring"
+ ],
+ "summary": "Get one backend operation trace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Trace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Backend operation trace",
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "404": {
+ "description": "Trace not found",
+ "schema": {
+ "$ref": "#/definitions/schema.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/api/branding": {
"get": {
"description": "Returns the configured instance name, tagline, and asset URLs. Public — no authentication required.",
@@ -1370,7 +1426,7 @@ const docTemplate = `{
},
"/api/traces": {
"get": {
- "description": "Returns captured API exchange traces (request/response pairs) in reverse chronological order",
+ "description": "Returns a bounded, newest-first page of captured API exchange traces. Request and response bodies plus headers are omitted unless full=true; fetch them per-trace from /api/traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.",
"produces": [
"application/json"
],
@@ -1378,6 +1434,26 @@ const docTemplate = `{
"monitoring"
],
"summary": "List API request/response traces",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Maximum entries to return (default 50, max 1000, 0 for all)",
+ "name": "limit",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Number of entries to skip (default 0)",
+ "name": "offset",
+ "in": "query"
+ },
+ {
+ "type": "boolean",
+ "description": "Include request/response bodies and headers (default false)",
+ "name": "full",
+ "in": "query"
+ }
+ ],
"responses": {
"200": {
"description": "Traced API exchanges",
@@ -1403,6 +1479,42 @@ const docTemplate = `{
}
}
},
+ "/api/traces/{id}": {
+ "get": {
+ "description": "Returns a single captured API exchange, including the request and response bodies omitted from the list response",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "monitoring"
+ ],
+ "summary": "Get one API trace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Trace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Traced API exchange",
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "404": {
+ "description": "Trace not found",
+ "schema": {
+ "$ref": "#/definitions/schema.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/api/voice-profiles": {
"get": {
"description": "List saved voice-cloning references without exposing filesystem paths.",
diff --git a/swagger/swagger.json b/swagger/swagger.json
index 4d074a148..d5729c847 100644
--- a/swagger/swagger.json
+++ b/swagger/swagger.json
@@ -596,7 +596,7 @@
},
"/api/backend-traces": {
"get": {
- "description": "Returns captured backend traces (LLM calls, embeddings, TTS, etc.) in reverse chronological order",
+ "description": "Returns a bounded, newest-first page of captured backend traces (LLM calls, embeddings, TTS, etc). The heavy body and data fields are omitted unless full=true; fetch them per-trace from /api/backend-traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.",
"produces": [
"application/json"
],
@@ -604,6 +604,26 @@
"monitoring"
],
"summary": "List backend operation traces",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Maximum entries to return (default 50, max 1000, 0 for all)",
+ "name": "limit",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Number of entries to skip (default 0)",
+ "name": "offset",
+ "in": "query"
+ },
+ {
+ "type": "boolean",
+ "description": "Include the body and data payloads (default false)",
+ "name": "full",
+ "in": "query"
+ }
+ ],
"responses": {
"200": {
"description": "Backend operation traces",
@@ -629,6 +649,42 @@
}
}
},
+ "/api/backend-traces/{id}": {
+ "get": {
+ "description": "Returns a single captured backend trace, including the body and data payloads omitted from the list response",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "monitoring"
+ ],
+ "summary": "Get one backend operation trace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Trace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Backend operation trace",
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "404": {
+ "description": "Trace not found",
+ "schema": {
+ "$ref": "#/definitions/schema.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/api/branding": {
"get": {
"description": "Returns the configured instance name, tagline, and asset URLs. Public — no authentication required.",
@@ -1367,7 +1423,7 @@
},
"/api/traces": {
"get": {
- "description": "Returns captured API exchange traces (request/response pairs) in reverse chronological order",
+ "description": "Returns a bounded, newest-first page of captured API exchange traces. Request and response bodies plus headers are omitted unless full=true; fetch them per-trace from /api/traces/{id}. Paging metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit headers.",
"produces": [
"application/json"
],
@@ -1375,6 +1431,26 @@
"monitoring"
],
"summary": "List API request/response traces",
+ "parameters": [
+ {
+ "type": "integer",
+ "description": "Maximum entries to return (default 50, max 1000, 0 for all)",
+ "name": "limit",
+ "in": "query"
+ },
+ {
+ "type": "integer",
+ "description": "Number of entries to skip (default 0)",
+ "name": "offset",
+ "in": "query"
+ },
+ {
+ "type": "boolean",
+ "description": "Include request/response bodies and headers (default false)",
+ "name": "full",
+ "in": "query"
+ }
+ ],
"responses": {
"200": {
"description": "Traced API exchanges",
@@ -1400,6 +1476,42 @@
}
}
},
+ "/api/traces/{id}": {
+ "get": {
+ "description": "Returns a single captured API exchange, including the request and response bodies omitted from the list response",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "monitoring"
+ ],
+ "summary": "Get one API trace",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Trace ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Traced API exchange",
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "404": {
+ "description": "Trace not found",
+ "schema": {
+ "$ref": "#/definitions/schema.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/api/voice-profiles": {
"get": {
"description": "List saved voice-cloning references without exposing filesystem paths.",
diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml
index e28632183..b22cef3c3 100644
--- a/swagger/swagger.yaml
+++ b/swagger/swagger.yaml
@@ -3093,8 +3093,24 @@ paths:
- monitoring
/api/backend-traces:
get:
- description: Returns captured backend traces (LLM calls, embeddings, TTS, etc.)
- in reverse chronological order
+ description: Returns a bounded, newest-first page of captured backend traces
+ (LLM calls, embeddings, TTS, etc). The heavy body and data fields are omitted
+ unless full=true; fetch them per-trace from /api/backend-traces/{id}. Paging
+ metadata is returned in the X-Total-Count, X-Trace-Offset and X-Trace-Limit
+ headers.
+ parameters:
+ - description: Maximum entries to return (default 50, max 1000, 0 for all)
+ in: query
+ name: limit
+ type: integer
+ - description: Number of entries to skip (default 0)
+ in: query
+ name: offset
+ type: integer
+ - description: Include the body and data payloads (default false)
+ in: query
+ name: full
+ type: boolean
produces:
- application/json
responses:
@@ -3106,6 +3122,31 @@ paths:
summary: List backend operation traces
tags:
- monitoring
+ /api/backend-traces/{id}:
+ get:
+ description: Returns a single captured backend trace, including the body and
+ data payloads omitted from the list response
+ parameters:
+ - description: Trace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Backend operation trace
+ schema:
+ additionalProperties: true
+ type: object
+ "404":
+ description: Trace not found
+ schema:
+ $ref: '#/definitions/schema.ErrorResponse'
+ summary: Get one backend operation trace
+ tags:
+ - monitoring
/api/backend-traces/clear:
post:
description: Removes all captured backend operation traces from the buffer
@@ -3628,8 +3669,23 @@ paths:
- router
/api/traces:
get:
- description: Returns captured API exchange traces (request/response pairs) in
- reverse chronological order
+ description: Returns a bounded, newest-first page of captured API exchange traces.
+ Request and response bodies plus headers are omitted unless full=true; fetch
+ them per-trace from /api/traces/{id}. Paging metadata is returned in the X-Total-Count,
+ X-Trace-Offset and X-Trace-Limit headers.
+ parameters:
+ - description: Maximum entries to return (default 50, max 1000, 0 for all)
+ in: query
+ name: limit
+ type: integer
+ - description: Number of entries to skip (default 0)
+ in: query
+ name: offset
+ type: integer
+ - description: Include request/response bodies and headers (default false)
+ in: query
+ name: full
+ type: boolean
produces:
- application/json
responses:
@@ -3641,6 +3697,31 @@ paths:
summary: List API request/response traces
tags:
- monitoring
+ /api/traces/{id}:
+ get:
+ description: Returns a single captured API exchange, including the request and
+ response bodies omitted from the list response
+ parameters:
+ - description: Trace ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: Traced API exchange
+ schema:
+ additionalProperties: true
+ type: object
+ "404":
+ description: Trace not found
+ schema:
+ $ref: '#/definitions/schema.ErrorResponse'
+ summary: Get one API trace
+ tags:
+ - monitoring
/api/traces/clear:
post:
description: Removes all captured API request/response traces from the buffer