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]
This commit is contained in:
Ettore Di Giacinto
2026-06-04 08:24:19 +00:00
parent 5470051d4d
commit 7b451058cc
4 changed files with 82 additions and 3 deletions

View File

@@ -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{

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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"))