Skip to content

Commit d1bbc99

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
fix(api): return 404 for unknown weight-file model names instead of gallery fallthrough
The model-existence guard in SetModelAndConfig skipped the check for any model name containing "/", to let diffusers-style HuggingFace "org/repo" IDs download on the fly. But a name like "local/model.gguf" (the parameters.model weight path, mistakenly passed as the request model) also contains "/", so it bypassed the guard and silently fell through to the gallery autoloader, which then attempted a surprising HuggingFace download (issue #10162). Tighten the guard so it only treats a "/"-containing name as a remote ID when it does NOT end in a recognized model-file extension. Names that point at a concrete weight file are now verified like any other, so a wrong name returns a clear 404 while a loose weight file addressed by its relative path (resolved by CheckIfModelExists against the models dir) still passes. The extension check reuses pkg/model's known-suffix list via the new HasKnownModelFileExtension helper, so version-style suffixes like the ".0" in "stabilityai/stable-diffusion-xl-base-1.0" are correctly treated as remote IDs. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code]
1 parent 8f9184f commit d1bbc99

4 files changed

Lines changed: 82 additions & 3 deletions

File tree

core/http/middleware/request.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,9 +152,15 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR
152152

153153
// If a model name was specified, verify it actually exists before proceeding.
154154
// Check both configured models and loose model files in the model path.
155-
// Skip the check for HuggingFace model IDs (contain "/") since backends
156-
// like diffusers may download these on the fly.
157-
if modelName != "" && !strings.Contains(modelName, "/") {
155+
// Skip the check only for HuggingFace-style model IDs ("org/repo") that
156+
// backends like diffusers may download on the fly. A name that points at a
157+
// concrete weight file (e.g. "local/model.gguf") is NOT such an ID: it must
158+
// still be verified, otherwise a wrong name silently falls through to the
159+
// gallery autoloader and triggers a surprising download (issue #10162).
160+
// CheckIfModelExists resolves relative paths against the models dir, so a
161+
// loose weight file addressed by path still passes.
162+
isRemoteModelID := strings.Contains(modelName, "/") && !model.HasKnownModelFileExtension(modelName)
163+
if modelName != "" && !isRemoteModelID {
158164
exists, existsErr := galleryop.CheckIfModelExists(re.modelConfigLoader, re.modelLoader, modelName, galleryop.ALWAYS_INCLUDE)
159165
if existsErr == nil && !exists {
160166
return c.JSON(http.StatusNotFound, schema.ErrorResponse{

core/http/middleware/request_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,40 @@ var _ = Describe("SetModelAndConfig middleware", func() {
140140
})
141141
})
142142

143+
Context("when the model name is a file path to a weight that does not exist", func() {
144+
// A name like "local/model.gguf" is the parameters.model weight path, not a
145+
// HuggingFace org/repo ID. The slash must not exempt it from the existence
146+
// check, otherwise a wrong name silently falls through to the gallery
147+
// autoloader and triggers a surprising download (issue #10162).
148+
It("returns 404 instead of passing through", func() {
149+
rec := postJSON(app, "/v1/chat/completions",
150+
`{"model":"local/missing-model.gguf","messages":[{"role":"user","content":"hi"}]}`)
151+
152+
Expect(rec.Code).To(Equal(http.StatusNotFound))
153+
154+
var resp schema.ErrorResponse
155+
Expect(json.Unmarshal(rec.Body.Bytes(), &resp)).To(Succeed())
156+
Expect(resp.Error).ToNot(BeNil())
157+
Expect(resp.Error.Message).To(ContainSubstring("local/missing-model.gguf"))
158+
Expect(resp.Error.Message).To(ContainSubstring("not found"))
159+
})
160+
})
161+
162+
Context("when the model name is a file path to a weight that exists on disk", func() {
163+
// The same path, but the loose weight file is actually present in a
164+
// subdirectory of the models path: the request must pass through so users
165+
// can address a raw weight file by its relative path.
166+
It("passes through to the handler", func() {
167+
Expect(os.MkdirAll(filepath.Join(modelDir, "local"), 0755)).To(Succeed())
168+
Expect(os.WriteFile(filepath.Join(modelDir, "local", "present-model.gguf"), []byte("weights"), 0644)).To(Succeed())
169+
170+
rec := postJSON(app, "/v1/chat/completions",
171+
`{"model":"local/present-model.gguf","messages":[{"role":"user","content":"hi"}]}`)
172+
173+
Expect(rec.Code).To(Equal(http.StatusOK))
174+
})
175+
})
176+
143177
Context("when no model is specified", func() {
144178
It("passes through without checking", func() {
145179
rec := postJSON(app, "/v1/chat/completions",

pkg/model/loader.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,28 @@ var knownModelsNameSuffixToSkip []string = []string{
369369
".tar.gz",
370370
}
371371

372+
// HasKnownModelFileExtension reports whether name ends in a file extension that
373+
// LocalAI recognizes as a model weight or asset file (e.g. ".gguf",
374+
// ".safetensors", ".json"). It is used to tell a concrete file path such as
375+
// "local/model.gguf" apart from a HuggingFace-style repository ID like
376+
// "org/repo": only the former carries a recognized suffix. A version-style
377+
// suffix such as the ".0" in "stabilityai/stable-diffusion-xl-base-1.0" is not
378+
// in the list, so such repo IDs are correctly treated as non-files.
379+
func HasKnownModelFileExtension(name string) bool {
380+
lower := strings.ToLower(name)
381+
for _, suffix := range knownModelsNameSuffixToSkip {
382+
// "." is a guard entry consumed by ListFilesInModelPath, not a real
383+
// extension; skip it so it doesn't match every dotted name.
384+
if suffix == "." {
385+
continue
386+
}
387+
if strings.HasSuffix(lower, strings.ToLower(suffix)) {
388+
return true
389+
}
390+
}
391+
return false
392+
}
393+
372394
func (ml *ModelLoader) ListFilesInModelPath() ([]string, error) {
373395
files, err := os.ReadDir(ml.ModelPath)
374396
if err != nil {

pkg/model/loader_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,23 @@ var _ = Describe("ModelLoader", func() {
100100
})
101101
})
102102

103+
Context("HasKnownModelFileExtension", func() {
104+
It("returns true for concrete weight/asset file paths", func() {
105+
Expect(model.HasKnownModelFileExtension("local/model.gguf")).To(BeTrue())
106+
Expect(model.HasKnownModelFileExtension("model.safetensors")).To(BeTrue())
107+
Expect(model.HasKnownModelFileExtension("foo/bar.GGUF")).To(BeTrue())
108+
Expect(model.HasKnownModelFileExtension("config.json")).To(BeTrue())
109+
})
110+
111+
It("returns false for HuggingFace-style repository IDs", func() {
112+
// org/repo carries no recognized file extension...
113+
Expect(model.HasKnownModelFileExtension("bartowski/DeepSeek-R1-Distill-Qwen-1.5B-GGUF")).To(BeFalse())
114+
// ...and a version suffix like ".0" is not a known model extension.
115+
Expect(model.HasKnownModelFileExtension("stabilityai/stable-diffusion-xl-base-1.0")).To(BeFalse())
116+
Expect(model.HasKnownModelFileExtension("plain-model-name")).To(BeFalse())
117+
})
118+
})
119+
103120
Context("ListFilesInModelPath", func() {
104121
It("should list all valid model files in the model path", func() {
105122
os.Create(filepath.Join(modelPath, "test.model"))

0 commit comments

Comments
 (0)