From f4ef801733396374886cce5ab60e36749dd90155 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Mon, 20 Jul 2026 07:43:25 +0000 Subject: [PATCH] fix(gallery): make deepseek-v4-flash variant targets installable Clicking install on deepseek-v4-flash failed with "invalid gallery model". The parent entry is fine, but all four entries it was grouped with declared neither url: nor config_file:, and applyModel needs one of the two to have anything to build a config from. They carry urls: (plural), the informational HuggingFace link list, which is a different field. None of the four was ever independently installable, so grouping them routed a previously-working install into a broken entry. Give each the url: the parent already resolves through. virtual.yaml is a no-op base, and applyModel passes overrides to InstallModel separately from the fetched config, so backend: ds4, the parameters and the ssd/mtp options all still land exactly as authored. This is the same pattern the parent and many other GGUF entries in the index already use. Add the lint rule that should have caught this. checkVariantReferences only proved a target exists and is not itself a parent, which is structural validity: an entry can exist, declare no variants, and still be uninstallable. checkVariantTargetsInstallable mirrors applyModel's precondition instead, and names the parent, the target and the missing fields, because whoever hits it is reading a gallery entry and has no reason to know applyModel exists. The two index-driven resolution specs live in their own Ordered container: an Ordered container stops at its first failure, so sharing one with the lint rules let a lint breach skip them silently. Nine further entries gallery-wide have the same defect and are unrelated to variants, so they are broken installs that predate this branch. They are left alone here rather than buried in a regression fix, and widening the rule to cover every entry is deferred with them so the gate can ratchet up in one step instead of needing a skip list. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto --- core/gallery/variants_lint_test.go | 182 +++++++++++++++++++++++++++++ gallery/index.yaml | 4 + 2 files changed, 186 insertions(+) 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