mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-05 07:16:10 -04:00
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:
@@ -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{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user