From 4a55022ddf18b17fad2cd48aa57c185dfb8555f1 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Fri, 17 Jul 2026 22:56:47 +0000 Subject: [PATCH] fix(model-artifacts): gate inferred artifact materialization by backend The managed-artifact materializer stages a HuggingFace snapshot into a directory (.artifacts/huggingface//snapshot/). That is the right load target for directory-consuming backends (transformers, vLLM, diffusers, ...), but PrimaryArtifactSpec inferred a managed artifact from ANY HuggingFace-shaped model reference regardless of backend. A single-file backend such as llama.cpp or whisper was therefore handed the snapshot directory instead of the weight file and failed to load it. The /import-model importer already guards this with a backend allow-list (managedArtifactBackends), but the loader-side inference did not. Move the allow-list into core/config as IsManagedArtifactBackend and apply it in PrimaryArtifactSpec: only directory-consuming backends may have an artifact inferred from a bare reference; every other backend stays on the legacy download-to-file path. An explicit artifacts: block still bypasses the gate, where single-file snapshot resolution handles the load path. The importer now shares the same predicate, so both paths agree on which backends auto-materialize. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto --- core/config/model_artifact_inference.go | 29 +++++++++++ core/config/model_artifact_inference_test.go | 53 ++++++++++++++++++++ core/gallery/importers/helpers.go | 9 +--- 3 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 core/config/model_artifact_inference_test.go diff --git a/core/config/model_artifact_inference.go b/core/config/model_artifact_inference.go index af9c00d21d1f..a011a7a6f7cb 100644 --- a/core/config/model_artifact_inference.go +++ b/core/config/model_artifact_inference.go @@ -9,6 +9,29 @@ import ( "github.com/mudler/LocalAI/pkg/modelartifacts" ) +// managedArtifactBackends is the set of backends that load a model from a +// snapshot *directory* (a HuggingFace repo consumed as a folder of weights, +// config, and tokenizer files). Only these backends may have a managed +// artifact *inferred* from a bare model reference: single-file backends such +// as llama.cpp or whisper would otherwise be handed the snapshot directory +// instead of the weight file and fail to load it. The importer relies on the +// same gate, so both paths agree on which backends auto-materialize. +var managedArtifactBackends = map[string]struct{}{ + "transformers": {}, "huggingface-embeddings": {}, "sentencetransformers": {}, + "transformers-musicgen": {}, "mamba": {}, "diffusers": {}, "qwen-asr": {}, + "fish-speech": {}, "nemo": {}, "voxcpm": {}, "qwen-tts": {}, + "liquid-audio": {}, "vllm": {}, "vllm-omni": {}, "sglang": {}, +} + +// IsManagedArtifactBackend reports whether backend consumes a model as a +// snapshot directory and is therefore eligible for inferred artifact +// materialization. An explicit artifacts: block bypasses this gate; single-file +// resolution handles the load path for single-file backends in that case. +func IsManagedArtifactBackend(backend string) bool { + _, ok := managedArtifactBackends[backend] + return ok +} + // PrimaryArtifactSpec returns the managed primary artifact to materialize for // this config. The boolean return is false when the config should stay on the // legacy path. @@ -24,6 +47,12 @@ func (c ModelConfig) PrimaryArtifactSpec(modelsPath string) (modelartifacts.Spec if len(c.DownloadFiles) > 0 { return modelartifacts.Spec{}, false, false, nil } + // Only directory-consuming backends may have an artifact inferred from a + // bare reference; single-file backends stay on the legacy download-to-file + // path so the backend receives the weight file itself, not its directory. + if !IsManagedArtifactBackend(c.Backend) { + return modelartifacts.Spec{}, false, false, nil + } if modelsPath != "" { for _, candidate := range []string{ diff --git a/core/config/model_artifact_inference_test.go b/core/config/model_artifact_inference_test.go new file mode 100644 index 000000000000..1bc5987ad540 --- /dev/null +++ b/core/config/model_artifact_inference_test.go @@ -0,0 +1,53 @@ +package config_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + + "github.com/mudler/LocalAI/core/config" +) + +var _ = Describe("PrimaryArtifactSpec backend gating", func() { + parse := func(doc string) config.ModelConfig { + var c config.ModelConfig + Expect(yaml.Unmarshal([]byte(doc), &c)).To(Succeed()) + return c + } + + It("does not infer a managed artifact for a single-file backend", func() { + // llama.cpp consumes a single GGUF file, not a snapshot directory. + // A bare HuggingFace file reference must stay on the legacy + // download-to-file path so the backend receives the file itself. + c := parse("backend: llama-cpp\nparameters: {model: huggingface://owner/repo/model.gguf}\n") + _, inferred, managed, err := c.PrimaryArtifactSpec("/models") + Expect(err).NotTo(HaveOccurred()) + Expect(managed).To(BeFalse()) + Expect(inferred).To(BeFalse()) + }) + + It("does not infer a managed artifact for a bare repo on a single-file backend", func() { + c := parse("backend: llama-cpp\nparameters: {model: owner/repo}\n") + _, _, managed, err := c.PrimaryArtifactSpec("/models") + Expect(err).NotTo(HaveOccurred()) + Expect(managed).To(BeFalse()) + }) + + It("infers a managed artifact for a directory-consuming backend", func() { + c := parse("backend: transformers\nparameters: {model: huggingface://owner/repo/model.safetensors}\n") + spec, inferred, managed, err := c.PrimaryArtifactSpec("/models") + Expect(err).NotTo(HaveOccurred()) + Expect(managed).To(BeTrue()) + Expect(inferred).To(BeTrue()) + Expect(spec.Source.Repo).To(Equal("owner/repo")) + }) + + It("keeps explicit artifacts managed even on a single-file backend", func() { + // An explicitly declared artifacts: block is a deliberate choice; + // single-file resolution (PrimaryFile) handles the load path. + c := parse("backend: llama-cpp\nartifacts:\n - source: {type: huggingface, repo: owner/repo}\nparameters: {model: owner/repo}\n") + _, _, managed, err := c.PrimaryArtifactSpec("/models") + Expect(err).NotTo(HaveOccurred()) + Expect(managed).To(BeTrue()) + }) +}) diff --git a/core/gallery/importers/helpers.go b/core/gallery/importers/helpers.go index 5345f3bed69a..8617ce751e58 100644 --- a/core/gallery/importers/helpers.go +++ b/core/gallery/importers/helpers.go @@ -12,13 +12,6 @@ import ( "gopkg.in/yaml.v3" ) -var managedArtifactBackends = map[string]struct{}{ - "transformers": {}, "huggingface-embeddings": {}, "sentencetransformers": {}, - "transformers-musicgen": {}, "mamba": {}, "diffusers": {}, "qwen-asr": {}, - "fish-speech": {}, "nemo": {}, "voxcpm": {}, "qwen-tts": {}, - "liquid-audio": {}, "vllm": {}, "vllm-omni": {}, "sglang": {}, -} - // AttachPrimaryArtifact adds the controller-managed source only when the // importer selected the same repository and a migrated backend. func AttachPrimaryArtifact(model gallery.ModelConfig, details Details) (gallery.ModelConfig, error) { @@ -29,7 +22,7 @@ func AttachPrimaryArtifact(model gallery.ModelConfig, details Details) (gallery. if err := yaml.Unmarshal([]byte(model.ConfigFile), &cfg); err != nil { return gallery.ModelConfig{}, err } - if _, supported := managedArtifactBackends[cfg.Backend]; !supported { + if !config.IsManagedArtifactBackend(cfg.Backend) { return model, nil } if len(cfg.Artifacts) != 0 || cfg.Model != details.HuggingFace.ModelID {