From 0c06be8aabcb77722768227a970c62d57b56bbc1 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Sat, 20 Jun 2026 09:41:32 +0000 Subject: [PATCH] feat(middleware): resolve model aliases and stamp requested/served identity Signed-off-by: Ettore Di Giacinto --- core/http/middleware/request.go | 21 ++++++ core/http/middleware/request_test.go | 101 +++++++++++++++++++++++++++ core/http/middleware/route_model.go | 7 +- 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/core/http/middleware/request.go b/core/http/middleware/request.go index ff0d929ac..74f7e8565 100644 --- a/core/http/middleware/request.go +++ b/core/http/middleware/request.go @@ -167,6 +167,27 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR } } + // Resolve a model alias to its target before the disabled check and + // before storing MODEL_CONFIG, so every modality (chat, embeddings, + // tts, image, ...) inherits redirection. The response keeps echoing + // the alias name (input.ModelName is left unchanged); usage accounting + // records requested=alias / served=target. + if cfg != nil && cfg.IsAlias() { + resolved, _, aliasErr := re.modelConfigLoader.ResolveAlias(cfg) + if aliasErr != nil { + return c.JSON(http.StatusBadRequest, schema.ErrorResponse{ + Error: &schema.APIError{ + Message: aliasErr.Error(), + Code: http.StatusBadRequest, + Type: "invalid_request_error", + }, + }) + } + c.Set(ContextKeyRequestedModel, modelName) + c.Set(ContextKeyServedModel, resolved.Name) + cfg = resolved + } + // Check if the model is disabled if cfg != nil && cfg.IsDisabled() { return c.JSON(http.StatusForbidden, schema.ErrorResponse{ diff --git a/core/http/middleware/request_test.go b/core/http/middleware/request_test.go index fe9fc926c..010379714 100644 --- a/core/http/middleware/request_test.go +++ b/core/http/middleware/request_test.go @@ -151,6 +151,107 @@ var _ = Describe("SetModelAndConfig middleware", func() { }) }) +// --------------------------------------------------------------------------- +// SetModelAndConfig - model alias resolution +// --------------------------------------------------------------------------- +// +// An alias config (`alias: `) is a pure redirect: the middleware must +// swap MODEL_CONFIG to the target config before the disabled check and before +// storing it, while leaving the response-facing model name as the alias. It +// also stamps routing.requested_model = alias and routing.served_model = +// target so usage accounting records both identities. +var _ = Describe("SetModelAndConfig alias resolution", func() { + var ( + modelDir string + capturedConfig *config.ModelConfig + capturedReq any + capturedServed any + app *echo.Echo + ) + + BeforeEach(func() { + var err error + modelDir, err = os.MkdirTemp("", "localai-alias-*") + Expect(err).ToNot(HaveOccurred()) + }) + + AfterEach(func() { + _ = os.RemoveAll(modelDir) + }) + + // buildApp seeds the loader from every YAML in modelDir (so an alias's + // target is present in the loader map) and wires a handler that captures + // the resolved config plus the stamped identity keys. + buildApp := func() *echo.Echo { + ss := &system.SystemState{Model: system.Model{ModelsPath: modelDir}} + appConfig := config.NewApplicationConfig() + appConfig.SystemState = ss + + mcl := config.NewModelConfigLoader(modelDir) + Expect(mcl.LoadModelConfigsFromPath(modelDir)).To(Succeed()) + ml := model.NewModelLoader(ss) + re := NewRequestExtractor(mcl, ml, appConfig) + + capturedConfig = nil + capturedReq = nil + capturedServed = nil + e := echo.New() + e.POST("/v1/chat/completions", + func(c echo.Context) error { + if cfg, ok := c.Get(CONTEXT_LOCALS_KEY_MODEL_CONFIG).(*config.ModelConfig); ok { + capturedConfig = cfg + } + capturedReq = c.Get(ContextKeyRequestedModel) + capturedServed = c.Get(ContextKeyServedModel) + return c.String(http.StatusOK, "ok") + }, + re.SetModelAndConfig(func() schema.LocalAIRequest { return new(schema.OpenAIRequest) }), + ) + return e + } + + It("serves the target config but keeps the alias name and stamps identity", func() { + Expect(os.WriteFile(filepath.Join(modelDir, "real.yaml"), + []byte("name: real\nbackend: llama-cpp\n"), 0644)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(modelDir, "gpt-4.yaml"), + []byte("name: gpt-4\nalias: real\n"), 0644)).To(Succeed()) + app = buildApp() + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", + strings.NewReader(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(capturedConfig).ToNot(BeNil()) + // MODEL_CONFIG must be the target, not the alias stub. + Expect(capturedConfig.Name).To(Equal("real")) + Expect(capturedConfig.IsAlias()).To(BeFalse()) + // Identity stamps: requested = alias, served = target. + Expect(capturedReq).To(Equal("gpt-4")) + Expect(capturedServed).To(Equal("real")) + }) + + It("returns 400 when the alias target is missing", func() { + Expect(os.WriteFile(filepath.Join(modelDir, "gpt-4.yaml"), + []byte("name: gpt-4\nalias: nope\n"), 0644)).To(Succeed()) + app = buildApp() + + req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", + strings.NewReader(`{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + var resp schema.ErrorResponse + Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Type).To(Equal("invalid_request_error")) + }) +}) + // --------------------------------------------------------------------------- // MergeOpenResponsesConfig — tool_choice parsing // --------------------------------------------------------------------------- diff --git a/core/http/middleware/route_model.go b/core/http/middleware/route_model.go index 7ff286af4..470bd05f5 100644 --- a/core/http/middleware/route_model.go +++ b/core/http/middleware/route_model.go @@ -189,7 +189,12 @@ func RouteModel(loader *config.ModelConfigLoader, appConfig *config.ApplicationC } c.Set(CONTEXT_LOCALS_KEY_MODEL_CONFIG, result.ChosenConfig) - c.Set(ContextKeyRequestedModel, result.RouterModel) + // Preserve an upstream requested model (e.g. an alias that points + // at this router model) so accounting keeps the name the client + // actually sent. Served always reflects the final candidate. + if c.Get(ContextKeyRequestedModel) == nil { + c.Set(ContextKeyRequestedModel, result.RouterModel) + } c.Set(ContextKeyServedModel, result.ChosenModel) if store != nil {