mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
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>
This commit is contained in:
committed by
GitHub
parent
8eb8376596
commit
ff299df453
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <base href> 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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]))
|
||||
})
|
||||
})
|
||||
|
||||
113
core/http/middleware/compress.go
Normal file
113
core/http/middleware/compress.go
Normal file
@@ -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,
|
||||
})
|
||||
}
|
||||
116
core/http/middleware/compress_test.go
Normal file
116
core/http/middleware/compress_test.go
Normal file
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
145
core/http/middleware/trace_pagination_test.go
Normal file
145
core/http/middleware/trace_pagination_test.go
Normal file
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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')
|
||||
|
||||
82
core/http/react-ui/e2e/traces-pagination.spec.js
Normal file
82
core/http/react-ui/e2e/traces-pagination.spec.js
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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() {
|
||||
<tbody>
|
||||
{sortedTraces.map((trace, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
|
||||
<tr onClick={() => toggleRow(i, trace)} style={{ cursor: 'pointer' }}>
|
||||
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
|
||||
<td><span className="badge badge-info">{trace.request?.method || '-'}</span></td>
|
||||
<td style={{ fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }}>{trace.request?.path || '-'}</td>
|
||||
@@ -658,7 +700,7 @@ export default function Traces() {
|
||||
{expandedRow === i && (
|
||||
<tr>
|
||||
<td colSpan="6" style={{ padding: 0 }}>
|
||||
<ApiTraceDetail trace={trace} />
|
||||
<ApiTraceDetail trace={detail && detail.id === trace.id ? detail : trace} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -682,7 +724,7 @@ export default function Traces() {
|
||||
<tbody>
|
||||
{sortedTraces.map((trace, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<tr onClick={() => setExpandedRow(expandedRow === i ? null : i)} style={{ cursor: 'pointer' }}>
|
||||
<tr onClick={() => toggleRow(i, trace)} style={{ cursor: 'pointer' }}>
|
||||
<td><i className={`fas fa-chevron-${expandedRow === i ? 'down' : 'right'}`} style={{ fontSize: '0.7rem' }} /></td>
|
||||
<td><span style={typeBadgeStyle(trace.type)}>{trace.type || '-'}</span></td>
|
||||
<td style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', whiteSpace: 'nowrap' }}>{formatDateTime(trace.timestamp)}</td>
|
||||
@@ -700,7 +742,7 @@ export default function Traces() {
|
||||
{expandedRow === i && (
|
||||
<tr>
|
||||
<td colSpan="7" style={{ padding: 0 }}>
|
||||
<BackendTraceDetail trace={trace} />
|
||||
<BackendTraceDetail trace={detail && detail.id === trace.id ? detail : trace} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
23
core/http/react-ui/src/utils/api.js
vendored
23
core/http/react-ui/src/utils/api.js
vendored
@@ -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, {}),
|
||||
}
|
||||
|
||||
|
||||
2
core/http/react-ui/src/utils/config.js
vendored
2
core/http/react-ui/src/utils/config.js
vendored
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
146
core/http/static_cache_test.go
Normal file
146
core/http/static_cache_test.go
Normal file
@@ -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]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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` |
|
||||
|
||||
116
swagger/docs.go
116
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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user