Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions core/http/middleware/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
34 changes: 34 additions & 0 deletions core/http/middleware/request_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
22 changes: 22 additions & 0 deletions pkg/model/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
17 changes: 17 additions & 0 deletions pkg/model/loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Loading