fix(chat): defer model fallback until after binding (#12621)

* fix(chat): defer model fallback until after binding

Explicit chat models need neither legacy Bearer discovery nor default
listing. Defer those fallbacks until binding finds no model, while
keeping body validation and fresh binding inside each failover attempt.

Expose the existing request-phase UUID through a response header and a
nil-safe accessor. Disabled timing creates neither a header nor an ID.
Document the contract and cover fallback, authentication, and retries.

Assisted-by: OpenAI:undisclosed
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(http): check diagnostic router cleanup

Assert that deferred router cleanup succeeds instead of ignoring its
error. This fixes the errcheck failure in the chat authentication test.

Assisted-by: OpenAI:undisclosed
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
mudler-agentandEttore Di Giacinto authored and GitHub committed 2026-10-11 00:26:40 +02:00
1 parent 297f7bd662
commit c3976a675b
7 files changed
+248 -3

No files matched your search

+44
View File
@@ -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))
}
}
}
})
})
+135 -1
View File
@@ -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 {
+18
View File
@@ -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)
+1 -2
View File
@@ -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 {
+27
View File
@@ -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.
+12
View File
@@ -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
+11
View File
@@ -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