From efdcbbe332d24674cb8ce2095f8832679d5f81c5 Mon Sep 17 00:00:00 2001 From: Richard Palethorpe Date: Tue, 31 Mar 2026 09:48:21 +0100 Subject: [PATCH] feat(api): Return 404 when model is not found except for model names in HF format (#9133) Signed-off-by: Richard Palethorpe --- core/http/app_test.go | 2 +- core/http/middleware/request.go | 28 ++++- core/http/middleware/request_test.go | 152 +++++++++++++++++++++++++ core/services/galleryop/list_models.go | 13 ++- 4 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 core/http/middleware/request_test.go diff --git a/core/http/app_test.go b/core/http/app_test.go index 1640451e1..ecfa0f3a9 100644 --- a/core/http/app_test.go +++ b/core/http/app_test.go @@ -1065,7 +1065,7 @@ parameters: It("returns errors", func() { _, err := client.CreateCompletion(context.TODO(), openai.CompletionRequest{Model: "foomodel", Prompt: testPrompt}) Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("error, status code: 500, status: 500 Internal Server Error, message: could not load model - all backends returned error:")) + Expect(err.Error()).To(ContainSubstring("error, status code: 404, status: 404 Not Found")) }) It("shows the external backend", func() { diff --git a/core/http/middleware/request.go b/core/http/middleware/request.go index 5f7c122cc..4fc93d553 100644 --- a/core/http/middleware/request.go +++ b/core/http/middleware/request.go @@ -140,13 +140,31 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR } } - cfg, err := re.modelConfigLoader.LoadModelConfigFileByNameDefaultOptions(input.ModelName(nil), re.applicationConfig) + modelName := input.ModelName(nil) + cfg, err := re.modelConfigLoader.LoadModelConfigFileByNameDefaultOptions(modelName, re.applicationConfig) if err != nil { - xlog.Warn("Model Configuration File not found", "model", input.ModelName(nil), "error", err) - } else if cfg.Model == "" && input.ModelName(nil) != "" { - xlog.Debug("config does not include model, using input", "input.ModelName", input.ModelName(nil)) - cfg.Model = input.ModelName(nil) + xlog.Warn("Model Configuration File not found", "model", modelName, "error", err) + } else if cfg.Model == "" && modelName != "" { + xlog.Debug("config does not include model, using input", "input.ModelName", modelName) + cfg.Model = modelName + } + + // If a model name was specified, verify it actually exists before proceeding. + // Check both configured models and loose model files in the model path. + // Skip the check for HuggingFace model IDs (contain "/") since backends + // like diffusers may download these on the fly. + if modelName != "" && !strings.Contains(modelName, "/") { + exists, existsErr := galleryop.CheckIfModelExists(re.modelConfigLoader, re.modelLoader, modelName, galleryop.ALWAYS_INCLUDE) + if existsErr == nil && !exists { + return c.JSON(http.StatusNotFound, schema.ErrorResponse{ + Error: &schema.APIError{ + Message: fmt.Sprintf("model %q not found. To see available models, call GET /v1/models", modelName), + Code: http.StatusNotFound, + Type: "invalid_request_error", + }, + }) + } } c.Set(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, input) diff --git a/core/http/middleware/request_test.go b/core/http/middleware/request_test.go new file mode 100644 index 000000000..da382dd41 --- /dev/null +++ b/core/http/middleware/request_test.go @@ -0,0 +1,152 @@ +package middleware_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/config" + . "github.com/mudler/LocalAI/core/http/middleware" + "github.com/mudler/LocalAI/core/schema" + "github.com/mudler/LocalAI/pkg/model" + "github.com/mudler/LocalAI/pkg/system" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// newRequestApp creates a minimal Echo app with SetModelAndConfig middleware. +func newRequestApp(re *RequestExtractor) *echo.Echo { + e := echo.New() + e.POST("/v1/chat/completions", + func(c echo.Context) error { + return c.String(http.StatusOK, "ok") + }, + re.SetModelAndConfig(func() schema.LocalAIRequest { + return new(schema.OpenAIRequest) + }), + ) + return e +} + +func postJSON(e *echo.Echo, path, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +var _ = Describe("SetModelAndConfig middleware", func() { + var ( + app *echo.Echo + modelDir string + ) + + BeforeEach(func() { + var err error + modelDir, err = os.MkdirTemp("", "localai-test-models-*") + Expect(err).ToNot(HaveOccurred()) + + ss := &system.SystemState{ + Model: system.Model{ModelsPath: modelDir}, + } + appConfig := config.NewApplicationConfig() + appConfig.SystemState = ss + + mcl := config.NewModelConfigLoader(modelDir) + ml := model.NewModelLoader(ss) + + re := NewRequestExtractor(mcl, ml, appConfig) + app = newRequestApp(re) + }) + + AfterEach(func() { + os.RemoveAll(modelDir) + }) + + Context("when the model does not exist", func() { + It("returns 404 with a helpful error message", func() { + rec := postJSON(app, "/v1/chat/completions", + `{"model":"nonexistent-model","messages":[{"role":"user","content":"hi"}]}`) + + Expect(rec.Code).To(Equal(http.StatusNotFound)) + + var resp schema.ErrorResponse + Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed()) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Message).To(ContainSubstring("nonexistent-model")) + Expect(resp.Error.Message).To(ContainSubstring("not found")) + Expect(resp.Error.Type).To(Equal("invalid_request_error")) + }) + }) + + Context("when the model exists as a config file", func() { + BeforeEach(func() { + cfgContent := []byte("name: test-model\nbackend: llama-cpp\n") + err := os.WriteFile(filepath.Join(modelDir, "test-model.yaml"), cfgContent, 0644) + Expect(err).ToNot(HaveOccurred()) + }) + + It("passes through to the handler", func() { + rec := postJSON(app, "/v1/chat/completions", + `{"model":"test-model","messages":[{"role":"user","content":"hi"}]}`) + + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when the model exists as a pre-loaded config", func() { + var mcl *config.ModelConfigLoader + + BeforeEach(func() { + // Simulate a model installed via gallery: config is loaded in memory + // (not just a YAML file on disk). Recreate the app with the pre-loaded config. + ss := &system.SystemState{ + Model: system.Model{ModelsPath: modelDir}, + } + appConfig := config.NewApplicationConfig() + appConfig.SystemState = ss + + mcl = config.NewModelConfigLoader(modelDir) + // Pre-load a config as if installed via gallery + cfgContent := []byte("name: gallery-model\nbackend: llama-cpp\nmodel: gallery-model\n") + err := os.WriteFile(filepath.Join(modelDir, "gallery-model.yaml"), cfgContent, 0644) + Expect(err).ToNot(HaveOccurred()) + Expect(mcl.ReadModelConfig(filepath.Join(modelDir, "gallery-model.yaml"))).To(Succeed()) + + ml := model.NewModelLoader(ss) + re := NewRequestExtractor(mcl, ml, appConfig) + app = newRequestApp(re) + }) + + It("passes through to the handler", func() { + rec := postJSON(app, "/v1/chat/completions", + `{"model":"gallery-model","messages":[{"role":"user","content":"hi"}]}`) + + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when the model name contains a slash (HuggingFace ID)", func() { + It("skips the existence check and passes through", func() { + rec := postJSON(app, "/v1/chat/completions", + `{"model":"stabilityai/stable-diffusion-xl-base-1.0","messages":[{"role":"user","content":"hi"}]}`) + + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + }) + + Context("when no model is specified", func() { + It("passes through without checking", func() { + rec := postJSON(app, "/v1/chat/completions", + `{"messages":[{"role":"user","content":"hi"}]}`) + + // No model name → middleware doesn't reject, handler runs + Expect(rec.Code).To(Equal(http.StatusOK)) + }) + }) +}) diff --git a/core/services/galleryop/list_models.go b/core/services/galleryop/list_models.go index 1a8eda25e..840d91f6b 100644 --- a/core/services/galleryop/list_models.go +++ b/core/services/galleryop/list_models.go @@ -59,5 +59,16 @@ func CheckIfModelExists(bcl *config.ModelConfigLoader, ml *model.ModelLoader, mo if err != nil { return false, err } - return (len(models) > 0), nil + if len(models) > 0 { + return true, nil + } + + // ListModels may not find raw model weight files (e.g. .ggml, .gguf) + // because ListFilesInModelPath skips known weight-file extensions. + // Fall back to checking if the file exists directly in the model path. + if ml.ExistsInModelPath(modelName) { + return true, nil + } + + return false, nil }