diff --git a/core/http/diagnostics_test.go b/core/http/diagnostics_test.go index f71c2c715..8c4491d4e 100644 --- a/core/http/diagnostics_test.go +++ b/core/http/diagnostics_test.go @@ -115,3 +115,47 @@ var _ = Describe("Diagnostics public API isolation", Serial, func() { } } }) + +var _ = Describe("Chat lazy fallback authentication", func() { + It("rejects unauthenticated explicit models before extraction", func() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + dir := GinkgoT().TempDir() + state, err := system.GetSystemState(system.WithModelPath(dir), system.WithBackendPath(dir)) + Expect(err).NotTo(HaveOccurred()) + var events []diagnostics.Event + app, err := application.New(config.WithContext(ctx), config.WithSystemState(state), config.WithDataPath(filepath.Join(dir, "data")), config.DisableRuntimeSettings, config.DisableAgentPool, config.DisableMCP, config.DisableMetricsEndpoint, config.WithDisableStats(true), config.WithApiKeys([]string{"secret"}), func(c *config.ApplicationConfig) { + c.DisableLocalAIAssistant = true + c.DiagnosticsRecorder = diagnostics.NewRecorder(func(e diagnostics.Event) { events = append(events, e) }) + }) + Expect(err).NotTo(HaveOccurred()) + defer func() { cancel(); Expect(app.Shutdown()).To(Succeed()) }() + router, err := api.API(app) + Expect(err).NotTo(HaveOccurred()) + defer func() { Expect(router.Close()).To(Succeed()) }() + for _, token := range []string{"", "wrong", "secret"} { + events = nil + r := httptest.NewRequest("POST", "/v1/chat/completions", strings.NewReader(`{"model":"missing"}`)) + r.Header.Set("Content-Type", "application/json") + r.Header.Set(diagnostics.HeaderDiagnosticID, "spoof") + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, r) + if token != "secret" { + Expect(w.Code).To(Equal(http.StatusUnauthorized)) + Expect(events).To(BeEmpty()) + Expect(w.Header().Get(diagnostics.HeaderDiagnosticID)).To(BeEmpty()) + } else { + Expect(w.Code).To(Equal(http.StatusNotFound)) + Expect(events).NotTo(BeEmpty()) + Expect(w.Header().Get(diagnostics.HeaderDiagnosticID)).To(Equal(events[0].ID)) + for _, event := range events { + Expect(event.Phase).NotTo(Equal(diagnostics.PhaseBearerLookup)) + Expect(event.Phase).NotTo(Equal(diagnostics.PhaseDefaultListing)) + } + } + } + }) +}) diff --git a/core/http/middleware/diagnostics_test.go b/core/http/middleware/diagnostics_test.go index ea921a11c..4b7d5cefa 100644 --- a/core/http/middleware/diagnostics_test.go +++ b/core/http/middleware/diagnostics_test.go @@ -49,6 +49,137 @@ var _ = Describe("Diagnostics extraction", func() { } return out } + It("skips chat fallback discovery for a bound model but retains body validation", func() { + e := echo.New() + e.POST("/", func(c echo.Context) error { return c.NoContent(204) }, + re.SetModelAndConfigWithDefault(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }, nil)) + req := httptest.NewRequest("POST", "/", strings.NewReader(`{"model":"sentinel-model"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer not-a-model") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(204)) + Expect(ends(diagnostics.PhaseBearerLookup)).To(BeEmpty()) + Expect(ends(diagnostics.PhaseDefaultListing)).To(BeEmpty()) + Expect(ends(diagnostics.PhaseBodyLookup)).To(HaveLen(1)) + }) + It("preserves lazy fallback precedence and malformed JSON behavior", func() { + for _, tc := range []struct { + body, path, query, bearer, local string + listing, lookup, code int + }{ + {body: `{}`, listing: 1, code: 204}, + {body: `{}`, bearer: "sentinel-model", lookup: 1, code: 204}, + {body: `{}`, bearer: "not-a-model", lookup: 1, listing: 1, code: 204}, + {body: `{}`, query: "?model=sentinel-model", bearer: "not-a-model", code: 204}, + {body: `{}`, path: "sentinel-model", query: "?model=missing", code: 204}, + {body: `{}`, local: "sentinel-model", path: "missing", code: 204}, + {body: `{"model":"sentinel-model"}`, bearer: "missing", local: "missing", code: 204}, + {body: `{`, bearer: "sentinel-model", code: 400}, + {body: `{"model":"missing"}`, bearer: "sentinel-model", code: 404}, + } { + events = nil + e := echo.New() + r := httptest.NewRequest("POST", "/"+tc.query, strings.NewReader(tc.body)) + r.Header.Set("Content-Type", "application/json") + if tc.bearer != "" { + r.Header.Set("Authorization", "Bearer "+tc.bearer) + } + w := httptest.NewRecorder() + c := e.NewContext(r, w) + c.SetParamNames("model") + c.SetParamValues(tc.path) + c.Set(CONTEXT_LOCALS_KEY_MODEL_NAME, tc.local) + h := re.SetModelAndConfigWithDefault(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }, nil)(func(c echo.Context) error { + Expect(c.Get(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST).(*schema.OpenAIRequest).Model).To(Equal("sentinel-model")) + return c.NoContent(204) + }) + if err := h(c); err != nil { + e.HTTPErrorHandler(err, c) + } + Expect(w.Code).To(Equal(tc.code)) + Expect(ends(diagnostics.PhaseDefaultListing)).To(HaveLen(tc.listing)) + Expect(ends(diagnostics.PhaseBearerLookup)).To(HaveLen(tc.lookup)) + } + }) + It("retains alias, disabled, regex existence, and form behavior on the lazy path", func() { + disabled := true + a := config.ModelConfig{Name: "alias", Alias: "sentinel-model"} + d := config.ModelConfig{Name: "disabled", Disabled: &disabled} + d.Model = d.Name + target := config.ModelConfig{Name: "sentinel-model"} + target.Model = target.Name + bcl.ReplaceModelConfigs([]config.ModelConfig{target, a, d}) + // Run the unchanged eager API as the compatibility reference as well. + for _, lazy := range []bool{false, true} { + for _, tc := range []struct { + body, contentType, served string + code int + }{ + {`{"model":"alias"}`, "application/json", "sentinel-model", 204}, + {`{"model":"disabled"}`, "application/json", "", 403}, + {`{"model":"sentinel-.*"}`, "application/json", "", 204}, + {"model=sentinel-model", "application/x-www-form-urlencoded", "sentinel-model", 204}, + } { + events = nil + e := echo.New() + chain := []echo.MiddlewareFunc{re.BuildFilteredFirstAvailableDefaultModel(nil), re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) })} + if lazy { + chain = []echo.MiddlewareFunc{re.SetModelAndConfigWithDefault(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }, nil)} + } + e.POST("/", func(c echo.Context) error { + Expect(c.Get(CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig).Name).To(Equal(tc.served)) + return c.NoContent(204) + }, chain...) + r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) + r.Header.Set("Content-Type", tc.contentType) + r.Header.Set("Authorization", "Bearer missing") + w := httptest.NewRecorder() + e.ServeHTTP(w, r) + Expect(w.Code).To(Equal(tc.code)) + if lazy { + Expect(ends(diagnostics.PhaseBearerLookup)).To(BeEmpty()) + Expect(ends(diagnostics.PhaseDefaultListing)).To(BeEmpty()) + } + } + } + }) + + It("sets the same ID before a streaming flush and emits no header when disabled", func() { + recorder := ac.DiagnosticsRecorder + for _, enabled := range []bool{false, true} { + ac.DiagnosticsRecorder = nil + if enabled { + ac.DiagnosticsRecorder = recorder + } + e := echo.New() + e.POST("/", func(c echo.Context) error { + id := diagnostics.RequestID(c.Request().Context()) + Expect(c.Response().Header().Get(diagnostics.HeaderDiagnosticID)).To(Equal(id)) + Expect(id != "").To(Equal(enabled)) + c.Response().WriteHeader(200) + c.Response().Flush() + return nil + }, re.SetModelAndConfigWithDefault(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }, nil)) + Expect(postJSON(e, "/", `{"model":"sentinel-model"}`).Code).To(Equal(200)) + } + }) + + It("exposes the diagnostic UUID before early errors and ignores client spoofing", func() { + e := echo.New() + e.POST("/", func(c echo.Context) error { return c.NoContent(204) }, + re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) })) + req := httptest.NewRequest("POST", "/", strings.NewReader(`{`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-LocalAI-Diagnostic-ID", "spoof") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(400)) + Expect(events).NotTo(BeEmpty()) + Expect(rec.Header().Get("X-LocalAI-Diagnostic-ID")).To(Equal(events[0].ID)) + Expect(events[0].ID).NotTo(Equal("spoof")) + }) + It("preserves anonymous default, JSON, bearer and query selection with timing off and on", func() { for _, tc := range []struct { body, query, bearer string @@ -174,10 +305,11 @@ var _ = Describe("Diagnostics extraction", func() { } diagnostics.Mark(c.Request().Context(), diagnostics.PhaseModelInit) if cfg.Name == primary.Name { + input.Prompt = "mutated" return echo.ErrBadGateway } return c.String(http.StatusOK, cfg.Name) - }, re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) })) + }, re.SetModelAndConfigWithDefault(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }, nil)) response := postJSON(e, "/", `{"model":"sentinel-chain","prompt":"sentinel-prompt"}`) Expect(attempts).To(Equal([]string{primary.Name, fallback.Name})) Expect(response.Code).To(Equal(http.StatusOK)) @@ -186,10 +318,12 @@ var _ = Describe("Diagnostics extraction", func() { Expect(response.Header().Get(HeaderFailover)).To(Equal("fallback")) if !enabled { Expect(events).To(BeEmpty()) + Expect(response.Header().Get(diagnostics.HeaderDiagnosticID)).To(BeEmpty()) continue } Expect(ends(diagnostics.PhaseExtraction)).To(HaveLen(2)) id := events[0].ID + Expect(response.Header().Get(diagnostics.HeaderDiagnosticID)).To(Equal(id)) _, err := uuid.Parse(id) Expect(err).NotTo(HaveOccurred()) for _, event := range events { diff --git a/core/http/middleware/request.go b/core/http/middleware/request.go index e545d4808..fd2b576e4 100644 --- a/core/http/middleware/request.go +++ b/core/http/middleware/request.go @@ -52,6 +52,7 @@ func (re *RequestExtractor) getRequestContext(c echo.Context) context.Context { observed := re.applicationConfig.DiagnosticsRecorder.Request(ctx) if observed != ctx { c.SetRequest(c.Request().WithContext(observed)) + c.Response().Header().Set(diagnostics.HeaderDiagnosticID, diagnostics.RequestID(observed)) } return observed } @@ -153,6 +154,17 @@ func (re *RequestExtractor) BuildFilteredFirstAvailableDefaultModel(filterFn con // TODO: If context and cancel above belong on all methods, move that part of above into here! // Otherwise, it's in its own method below for now func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIRequest) echo.MiddlewareFunc { + return re.setModelAndConfig(initializer, nil) +} + +// SetModelAndConfigWithDefault defers legacy model discovery until binding has +// established that the request has no model. Binding stays inside each retry. +func (re *RequestExtractor) SetModelAndConfigWithDefault(initializer func() schema.LocalAIRequest, filter config.ModelConfigFilterFn) echo.MiddlewareFunc { + fallback := re.BuildFilteredFirstAvailableDefaultModel(filter)(func(echo.Context) error { return nil }) + return re.setModelAndConfig(initializer, fallback) +} + +func (re *RequestExtractor) setModelAndConfig(initializer func() schema.LocalAIRequest, fallback echo.HandlerFunc) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { retry := re.failoverRetry(func(c echo.Context) (err error) { finish := re.beginExtraction(c) @@ -165,6 +177,12 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("failed parsing request body: %v", err)) } + if input.ModelName(nil) == "" && fallback != nil { + if err := fallback(c); err != nil { + return err + } + } + // If this request doesn't have an associated model name, fetch it from earlier in the middleware chain if input.ModelName(nil) == "" { localModelName, ok := c.Get(CONTEXT_LOCALS_KEY_MODEL_NAME).(string) diff --git a/core/http/routes/openai.go b/core/http/routes/openai.go index b041305bc..fc4831b04 100644 --- a/core/http/routes/openai.go +++ b/core/http/routes/openai.go @@ -54,8 +54,7 @@ func RegisterOpenAIRoutes(app *echo.Echo, nodeHeaderMiddleware, usageMiddleware, traceMiddleware, - re.BuildFilteredFirstAvailableDefaultModel(config.BuildUsecaseFilterFn(config.FLAG_CHAT)), - re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }), + re.SetModelAndConfigWithDefault(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }, config.BuildUsecaseFilterFn(config.FLAG_CHAT)), func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { if err := re.SetOpenAIRequest(c); err != nil { diff --git a/docs/content/features/diagnostics.md b/docs/content/features/diagnostics.md index 05630e8ac..df5a54523 100644 --- a/docs/content/features/diagnostics.md +++ b/docs/content/features/diagnostics.md @@ -183,3 +183,30 @@ They retain every preexisting invocation, including each parsed model's `ModelCo See the [CLI reference]({{% relref "reference/cli-reference" %}}) for other server options. The [troubleshooting guide]({{% relref "getting-started/troubleshooting" %}}) covers broader operational checks. + +### Response correlation + +With `--request-phase-timing` (`LOCALAI_REQUEST_PHASE_TIMING=true`), requests that reach model extraction receive `X-LocalAI-Diagnostic-ID`. +The header contains the same server-generated UUID v4 as that request's phase events. +LocalAI sets it before extraction errors or streaming output can write the response. +Repeated extraction and failover attempts retain one ID for the logical request. + +Client-supplied diagnostic or correlation headers do not select this ID. +The ID contains no model name, authentication token, or request content. +It is a correlation value, not an authorization credential or a distributed trace ID. +Requests rejected before extraction, including global authentication failures, do not receive this header. +Disabling request-phase timing emits no diagnostic header and creates no diagnostic identity. +Enabling pprof or debug logging alone does not enable the header. + +Go callers can use `diagnostics.RequestID(ctx)` to read the existing request ID without creating one. +The accessor returns an empty string for nil contexts, unobserved contexts, and background reload observations. + +### Chat model fallback + +OpenAI chat completions bind the request before considering legacy model discovery. +An explicit bound model skips Bearer-as-model lookup and default candidate listing; model existence validation still runs. +If the bound model is empty, extraction retains context, path, query, form, Bearer, and first-available fallback precedence. +The existing candidate filter and ordering still select the default. +Malformed JSON returns a parsing error without fallback discovery. +Each failover attempt binds fresh input from the replayed body. +Other API routes retain their existing extraction order. diff --git a/pkg/diagnostics/timing.go b/pkg/diagnostics/timing.go index cb6a47c55..2971efc76 100644 --- a/pkg/diagnostics/timing.go +++ b/pkg/diagnostics/timing.go @@ -91,6 +91,18 @@ func observed(ctx context.Context) *observation { return o } +// HeaderDiagnosticID correlates an observed HTTP request with phase events. +const HeaderDiagnosticID = "X-LocalAI-Diagnostic-ID" + +// RequestID returns the server-generated request UUID, or an empty string for +// nil, unobserved, or background-operation contexts. It never creates an ID. +func RequestID(ctx context.Context) string { + if o := observed(ctx); o != nil && o.kind == KindRequest { + return o.id + } + return "" +} + func (r *Recorder) Request(ctx context.Context) context.Context { if r == nil { return ctx diff --git a/pkg/diagnostics/timing_test.go b/pkg/diagnostics/timing_test.go index 833fbfd8c..ec41721f7 100644 --- a/pkg/diagnostics/timing_test.go +++ b/pkg/diagnostics/timing_test.go @@ -19,6 +19,17 @@ import ( ) var _ = Describe("Diagnostics timing", func() { + It("returns only an existing request ID without allocating", func() { + Expect(RequestID(nil)).To(BeEmpty()) + Expect(RequestID(context.Background())).To(BeEmpty()) + r := NewRecorder(func(Event) {}) + Expect(RequestID(r.Reload(context.Background()))).To(BeEmpty()) + ctx := r.Request(context.Background()) + Expect(RequestID(ctx)).NotTo(BeEmpty()) + Expect(RequestID(r.Request(ctx))).To(Equal(RequestID(ctx))) + Expect(testing.AllocsPerRun(100, func() { RequestID(context.Background()); RequestID(nil); RequestID(ctx) })).To(BeZero()) + }) + It("keeps disabled calls inert and allocation free", func() { ctx := context.Background() var r *Recorder