diff --git a/core/gallery/variants_lint_test.go b/core/gallery/variants_lint_test.go index 8e7aef94d..5e925f0ff 100644 --- a/core/gallery/variants_lint_test.go +++ b/core/gallery/variants_lint_test.go @@ -75,6 +75,49 @@ func checkVariantReferences(entries []gallery.GalleryModel) []variantViolation { return violations } +// checkVariantTargetsInstallable verifies every entry referenced as a variant +// carries the payload an install needs, which is a non-empty url or a non-empty +// config_file. +// +// This mirrors the precondition InstallModelFromGallery's applyModel enforces: +// with neither field it has nothing to build a config from and fails with +// "invalid gallery model". Structural validity is not enough, because an entry +// can exist, declare no variants of its own, and still be uninstallable. That +// gap is how a grouping shipped whose every target failed on click: the parent +// resolved correctly and then routed the install into a dead entry. +// +// The message names the parent, the target and what is missing, because whoever +// hits this is reading a gallery entry and has no reason to know applyModel +// exists. +func checkVariantTargetsInstallable(entries []gallery.GalleryModel) []variantViolation { + byName := indexEntriesByName(entries) + var violations []variantViolation + for _, e := range entries { + if !e.HasVariants() { + continue + } + for _, v := range e.Variants { + target, ok := byName[v.Model] + if !ok { + // checkVariantReferences already reports the dangling name, and + // reporting it twice buries two distinct rules under duplicates. + continue + } + if len(target.URL) == 0 && len(target.ConfigFile) == 0 { + violations = append(violations, variantViolation{ + Entry: e.Name, + Variant: v.Model, + Detail: fmt.Sprintf("entry %q is not installable on its own: it declares neither url: nor config_file:, "+ + "so installing it fails with \"invalid gallery model\". Give it the url: its family uses "+ + "(commonly github:mudler/LocalAI/gallery/virtual.yaml@master) or an inline config_file:. "+ + "urls: (plural) is informational only and does not satisfy this", v.Model), + }) + } + } + } + return violations +} + // loadGalleryIndex parses gallery/index.yaml once for the whole suite. The // index carries well over a thousand entries, so re-parsing it per spec is // pure overhead. @@ -139,6 +182,69 @@ var _ = Describe("gallery variant lint helpers", func() { ) Expect(checkVariantReferences(entries)).To(BeEmpty()) + Expect(checkVariantTargetsInstallable(entries)).To(BeEmpty()) + }) + + Describe("checkVariantTargetsInstallable", func() { + // The defect this rule exists for: a target carrying everything except + // the one field applyModel reads. + uninstallable := func(name string) gallery.GalleryModel { + e := gallery.GalleryModel{} + e.Name = name + // urls: is the informational HuggingFace link list, and the entry + // that shipped broken had exactly this and nothing else. It must + // not be mistaken for url:. + e.URLs = []string{"https://huggingface.co/example/" + name} + e.Overrides = map[string]any{"backend": "ds4"} + return e + } + + It("flags a target with neither url nor config_file", func() { + entries := variantFixture( + entryWithVariants("base", "u://base", gallery.Variant{Model: "dead"}), + uninstallable("dead"), + ) + + violations := checkVariantTargetsInstallable(entries) + Expect(violations).To(HaveLen(1)) + Expect(violations[0].Entry).To(Equal("base")) + Expect(violations[0].Variant).To(Equal("dead")) + Expect(violations[0].Detail).To(ContainSubstring("neither url: nor config_file:")) + Expect(violations[0].Detail).To(ContainSubstring("invalid gallery model")) + }) + + It("accepts a target described by an inline config_file rather than a url", func() { + target := gallery.GalleryModel{ConfigFile: map[string]any{"backend": "llama-cpp"}} + target.Name = "inline" + entries := variantFixture( + entryWithVariants("base", "u://base", gallery.Variant{Model: "inline"}), + target, + ) + + Expect(checkVariantTargetsInstallable(entries)).To(BeEmpty()) + }) + + It("leaves an unknown target to checkVariantReferences rather than reporting it twice", func() { + entries := variantFixture( + entryWithVariants("base", "u://base", gallery.Variant{Model: "ghost"}), + ) + + Expect(checkVariantTargetsInstallable(entries)).To(BeEmpty()) + Expect(checkVariantReferences(entries)).To(HaveLen(1)) + }) + + It("reports every breach in one pass rather than stopping at the first", func() { + entries := variantFixture( + entryWithVariants("base", "u://base", + gallery.Variant{Model: "dead-a"}, + gallery.Variant{Model: "dead-b"}, + ), + uninstallable("dead-a"), + uninstallable("dead-b"), + ) + + Expect(checkVariantTargetsInstallable(entries)).To(HaveLen(2)) + }) }) Describe("checkVariantReferences", func() { @@ -197,4 +303,80 @@ var _ = Describe("gallery/index.yaml variant invariants", Ordered, func() { v := checkVariantReferences(entries) Expect(v).To(BeEmpty(), formatViolations(v)) }) + + It("references only entries that are installable on their own", func() { + v := checkVariantTargetsInstallable(entries) + Expect(v).To(BeEmpty(), formatViolations(v)) + }) +}) + +// The lint rules above check the catalog as text. This drives the real +// resolution path for the entry a user actually clicked and failed to install, +// so the fix is proven at the layer that broke and not only at the layer that +// should have caught it. +// +// It is a container of its own rather than another spec beside the lint rules +// because an Ordered container stops at its first failure: sharing one would +// let a lint breach skip these silently, which is precisely the kind of +// vacuously-green spec this file exists to avoid. +var _ = Describe("gallery/index.yaml deepseek-v4-flash resolution", Ordered, func() { + var entries []gallery.GalleryModel + var models []*gallery.GalleryModel + var entry *gallery.GalleryModel + + BeforeAll(func() { + var err error + entries, err = loadGalleryIndex() + Expect(err).ToNot(HaveOccurred()) + Expect(entries).ToNot(BeEmpty()) + }) + + BeforeEach(func() { + models = make([]*gallery.GalleryModel, 0, len(entries)) + for i := range entries { + models = append(models, &entries[i]) + } + entry = gallery.FindGalleryElement(models, "deepseek-v4-flash") + Expect(entry).ToNot(BeNil()) + Expect(entry.HasVariants()).To(BeTrue()) + }) + + // The unpinned pass covers the entry the user clicks. It does NOT prove the + // variants are installable: with no probe wired every size is unknown and + // the ranking ties, so the base wins and this only ever exercises the + // parent's own url. The pin spec below is what covers the four targets. + // + // Note that a base selection reports Variant.Model as the ENTRY's name + // rather than as empty, so asserting a non-empty Model here would look like + // a check that a declared variant won while passing on the base every time. + It("yields an installable entry for the entry itself", func() { + env := gallery.ResolveEnv{ + AvailableMemory: 512 << 30, + BackendCompatible: func(string) bool { return true }, + } + + resolved, selected, err := gallery.ResolveVariant(models, entry, env, "") + Expect(err).ToNot(HaveOccurred()) + Expect(len(resolved.URL) > 0 || len(resolved.ConfigFile) > 0).To(BeTrue(), + "resolved entry %q (variant %q) has neither url nor config_file, so InstallModelFromGallery would fail with \"invalid gallery model\"", + resolved.Name, selected.Model) + }) + + // Pinning reaches each target directly, which is what actually proves all + // four are installable: every one of them is resolved, not just whichever + // the ranking happens to prefer. This is the spec that fails on the + // unfixed index. + It("yields an installable entry for every declared variant pin", func() { + env := gallery.ResolveEnv{ + AvailableMemory: 512 << 30, + BackendCompatible: func(string) bool { return true }, + } + + for _, v := range entry.Variants { + resolved, _, err := gallery.ResolveVariant(models, entry, env, v.Model) + Expect(err).ToNot(HaveOccurred(), "pinning %q", v.Model) + Expect(len(resolved.URL) > 0 || len(resolved.ConfigFile) > 0).To(BeTrue(), + "variant %q has neither url nor config_file", v.Model) + } + }) }) diff --git a/gallery/index.yaml b/gallery/index.yaml index 70205d322..a6022de92 100644 --- a/gallery/index.yaml +++ b/gallery/index.yaml @@ -36884,6 +36884,7 @@ sha256: c61cbb396e2a8175d8b2da51f0fdac885a4ccd22c9f64dafa5aa2c455dc8a507 uri: huggingface://unsloth/LTX-2.3-GGUF/text_encoders/ltx-2.3-22b-distilled_embeddings_connectors.safetensors - name: deepseek-v4-flash-q2 + url: "github:mudler/LocalAI/gallery/virtual.yaml@master" description: | DeepSeek V4 Flash (IQ2XXS GGUF, ~81 GB) - only loadable via the ds4 backend. Requires >=128 GB RAM. Metal (Darwin) or CUDA (Linux). @@ -36905,6 +36906,7 @@ sha256: 31598c67c8b8744d3bcebcd19aa62253c6dc43cef3b8adf9f593656c9e86fd8c uri: huggingface://antirez/deepseek-v4-gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2.gguf - name: deepseek-v4-flash-q2-q4 + url: "github:mudler/LocalAI/gallery/virtual.yaml@master" description: | DeepSeek V4 Flash (mixed q2/q4 GGUF, ~91 GB) - only loadable via the ds4 backend. The last 6 expert layers are kept at Q4_K (the rest IQ2XXS), trading a little @@ -36928,6 +36930,7 @@ sha256: edabc92af63ad8b139f00087fbfc10a4072f37b7597f4fd9ad1dfa6f83002396 uri: huggingface://antirez/deepseek-v4-gguf/DeepSeek-V4-Flash-Layers37-42Q4KExperts-OtherExpertLayersIQ2XXSGateUp-Q2KDown-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix-fixed.gguf - name: deepseek-v4-flash-q4-ssd + url: "github:mudler/LocalAI/gallery/virtual.yaml@master" description: | DeepSeek V4 Flash (full 4-bit experts GGUF, ~153 GB) - only loadable via the ds4 backend, with SSD streaming enabled so it runs on a 128 GB machine even @@ -36955,6 +36958,7 @@ sha256: 39e5de72ac544fdd5ffaf83ec28e36aaf3341b145235488e67d59400bbb3af55 uri: huggingface://antirez/deepseek-v4-gguf/DeepSeek-V4-Flash-Q4KExperts-F16HC-F16Compressor-F16Indexer-Q8Attn-Q8Shared-Q8Out-chat-v2.gguf - name: deepseek-v4-flash-q2-mtp + url: "github:mudler/LocalAI/gallery/virtual.yaml@master" description: | DeepSeek V4 Flash (IQ2XXS GGUF, ~81 GB) paired with the optional MTP speculative-decoding weights (~3.5 GB) for a slight speedup. Only loadable