mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 00:59:28 -04:00
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:
1 parent
297f7bd662
commit
c3976a675b
7 files changed
+248
-3
No files matched your search
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user