diff --git a/core/http/middleware/request.go b/core/http/middleware/request.go index 3e6f3555a..f375a2c93 100644 --- a/core/http/middleware/request.go +++ b/core/http/middleware/request.go @@ -152,9 +152,15 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR // 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, "/") { + // Skip the check only for HuggingFace-style model IDs ("org/repo") that + // backends like diffusers may download on the fly. A name that points at a + // concrete weight file (e.g. "local/model.gguf") is NOT such an ID: it must + // still be verified, otherwise a wrong name silently falls through to the + // gallery autoloader and triggers a surprising download (issue #10162). + // CheckIfModelExists resolves relative paths against the models dir, so a + // loose weight file addressed by path still passes. + isRemoteModelID := strings.Contains(modelName, "/") && !model.HasKnownModelFileExtension(modelName) + if modelName != "" && !isRemoteModelID { exists, existsErr := galleryop.CheckIfModelExists(re.modelConfigLoader, re.modelLoader, modelName, galleryop.ALWAYS_INCLUDE) if existsErr == nil && !exists { return c.JSON(http.StatusNotFound, schema.ErrorResponse{ diff --git a/core/http/middleware/request_test.go b/core/http/middleware/request_test.go index 04c30dee4..db6aa1911 100644 --- a/core/http/middleware/request_test.go +++ b/core/http/middleware/request_test.go @@ -140,6 +140,40 @@ var _ = Describe("SetModelAndConfig middleware", func() { }) }) + Context("when the model name is a file path to a weight that does not exist", func() { + // A name like "local/model.gguf" is the parameters.model weight path, not a + // HuggingFace org/repo ID. The slash must not exempt it from the existence + // check, otherwise a wrong name silently falls through to the gallery + // autoloader and triggers a surprising download (issue #10162). + It("returns 404 instead of passing through", func() { + rec := postJSON(app, "/v1/chat/completions", + `{"model":"local/missing-model.gguf","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("local/missing-model.gguf")) + Expect(resp.Error.Message).To(ContainSubstring("not found")) + }) + }) + + Context("when the model name is a file path to a weight that exists on disk", func() { + // The same path, but the loose weight file is actually present in a + // subdirectory of the models path: the request must pass through so users + // can address a raw weight file by its relative path. + It("passes through to the handler", func() { + Expect(os.MkdirAll(filepath.Join(modelDir, "local"), 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(modelDir, "local", "present-model.gguf"), []byte("weights"), 0644)).To(Succeed()) + + rec := postJSON(app, "/v1/chat/completions", + `{"model":"local/present-model.gguf","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", diff --git a/pkg/model/loader.go b/pkg/model/loader.go index 2aebdb3c3..413417938 100644 --- a/pkg/model/loader.go +++ b/pkg/model/loader.go @@ -207,6 +207,28 @@ var knownModelsNameSuffixToSkip []string = []string{ ".tar.gz", } +// HasKnownModelFileExtension reports whether name ends in a file extension that +// LocalAI recognizes as a model weight or asset file (e.g. ".gguf", +// ".safetensors", ".json"). It is used to tell a concrete file path such as +// "local/model.gguf" apart from a HuggingFace-style repository ID like +// "org/repo": only the former carries a recognized suffix. A version-style +// suffix such as the ".0" in "stabilityai/stable-diffusion-xl-base-1.0" is not +// in the list, so such repo IDs are correctly treated as non-files. +func HasKnownModelFileExtension(name string) bool { + lower := strings.ToLower(name) + for _, suffix := range knownModelsNameSuffixToSkip { + // "." is a guard entry consumed by ListFilesInModelPath, not a real + // extension; skip it so it doesn't match every dotted name. + if suffix == "." { + continue + } + if strings.HasSuffix(lower, strings.ToLower(suffix)) { + return true + } + } + return false +} + const retryTimeout = time.Duration(2 * time.Minute) func (ml *ModelLoader) ListFilesInModelPath() ([]string, error) { diff --git a/pkg/model/loader_test.go b/pkg/model/loader_test.go index bb03c8942..c53693738 100644 --- a/pkg/model/loader_test.go +++ b/pkg/model/loader_test.go @@ -58,6 +58,23 @@ var _ = Describe("ModelLoader", func() { }) }) + Context("HasKnownModelFileExtension", func() { + It("returns true for concrete weight/asset file paths", func() { + Expect(model.HasKnownModelFileExtension("local/model.gguf")).To(BeTrue()) + Expect(model.HasKnownModelFileExtension("model.safetensors")).To(BeTrue()) + Expect(model.HasKnownModelFileExtension("foo/bar.GGUF")).To(BeTrue()) + Expect(model.HasKnownModelFileExtension("config.json")).To(BeTrue()) + }) + + It("returns false for HuggingFace-style repository IDs", func() { + // org/repo carries no recognized file extension... + Expect(model.HasKnownModelFileExtension("bartowski/DeepSeek-R1-Distill-Qwen-1.5B-GGUF")).To(BeFalse()) + // ...and a version suffix like ".0" is not a known model extension. + Expect(model.HasKnownModelFileExtension("stabilityai/stable-diffusion-xl-base-1.0")).To(BeFalse()) + Expect(model.HasKnownModelFileExtension("plain-model-name")).To(BeFalse()) + }) + }) + Context("ListFilesInModelPath", func() { It("should list all valid model files in the model path", func() { os.Create(filepath.Join(modelPath, "test.model"))