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 <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-07-20 07:43:25 +00:00
parent 72260d8b03
commit f4ef801733
2 changed files with 186 additions and 0 deletions

View File

@@ -75,6 +75,49 @@ func checkVariantReferences(entries []gallery.GalleryModel) []variantViolation {
return violations 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 // 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 // index carries well over a thousand entries, so re-parsing it per spec is
// pure overhead. // pure overhead.
@@ -139,6 +182,69 @@ var _ = Describe("gallery variant lint helpers", func() {
) )
Expect(checkVariantReferences(entries)).To(BeEmpty()) 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() { Describe("checkVariantReferences", func() {
@@ -197,4 +303,80 @@ var _ = Describe("gallery/index.yaml variant invariants", Ordered, func() {
v := checkVariantReferences(entries) v := checkVariantReferences(entries)
Expect(v).To(BeEmpty(), formatViolations(v)) 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)
}
})
}) })

View File

@@ -36884,6 +36884,7 @@
sha256: c61cbb396e2a8175d8b2da51f0fdac885a4ccd22c9f64dafa5aa2c455dc8a507 sha256: c61cbb396e2a8175d8b2da51f0fdac885a4ccd22c9f64dafa5aa2c455dc8a507
uri: huggingface://unsloth/LTX-2.3-GGUF/text_encoders/ltx-2.3-22b-distilled_embeddings_connectors.safetensors uri: huggingface://unsloth/LTX-2.3-GGUF/text_encoders/ltx-2.3-22b-distilled_embeddings_connectors.safetensors
- name: deepseek-v4-flash-q2 - name: deepseek-v4-flash-q2
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
description: | description: |
DeepSeek V4 Flash (IQ2XXS GGUF, ~81 GB) - only loadable via the ds4 backend. DeepSeek V4 Flash (IQ2XXS GGUF, ~81 GB) - only loadable via the ds4 backend.
Requires >=128 GB RAM. Metal (Darwin) or CUDA (Linux). Requires >=128 GB RAM. Metal (Darwin) or CUDA (Linux).
@@ -36905,6 +36906,7 @@
sha256: 31598c67c8b8744d3bcebcd19aa62253c6dc43cef3b8adf9f593656c9e86fd8c sha256: 31598c67c8b8744d3bcebcd19aa62253c6dc43cef3b8adf9f593656c9e86fd8c
uri: huggingface://antirez/deepseek-v4-gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2.gguf uri: huggingface://antirez/deepseek-v4-gguf/DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2.gguf
- name: deepseek-v4-flash-q2-q4 - name: deepseek-v4-flash-q2-q4
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
description: | description: |
DeepSeek V4 Flash (mixed q2/q4 GGUF, ~91 GB) - only loadable via the ds4 backend. 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 The last 6 expert layers are kept at Q4_K (the rest IQ2XXS), trading a little
@@ -36928,6 +36930,7 @@
sha256: edabc92af63ad8b139f00087fbfc10a4072f37b7597f4fd9ad1dfa6f83002396 sha256: edabc92af63ad8b139f00087fbfc10a4072f37b7597f4fd9ad1dfa6f83002396
uri: huggingface://antirez/deepseek-v4-gguf/DeepSeek-V4-Flash-Layers37-42Q4KExperts-OtherExpertLayersIQ2XXSGateUp-Q2KDown-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix-fixed.gguf 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 - name: deepseek-v4-flash-q4-ssd
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
description: | description: |
DeepSeek V4 Flash (full 4-bit experts GGUF, ~153 GB) - only loadable via the 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 ds4 backend, with SSD streaming enabled so it runs on a 128 GB machine even
@@ -36955,6 +36958,7 @@
sha256: 39e5de72ac544fdd5ffaf83ec28e36aaf3341b145235488e67d59400bbb3af55 sha256: 39e5de72ac544fdd5ffaf83ec28e36aaf3341b145235488e67d59400bbb3af55
uri: huggingface://antirez/deepseek-v4-gguf/DeepSeek-V4-Flash-Q4KExperts-F16HC-F16Compressor-F16Indexer-Q8Attn-Q8Shared-Q8Out-chat-v2.gguf 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 - name: deepseek-v4-flash-q2-mtp
url: "github:mudler/LocalAI/gallery/virtual.yaml@master"
description: | description: |
DeepSeek V4 Flash (IQ2XXS GGUF, ~81 GB) paired with the optional MTP DeepSeek V4 Flash (IQ2XXS GGUF, ~81 GB) paired with the optional MTP
speculative-decoding weights (~3.5 GB) for a slight speedup. Only loadable speculative-decoding weights (~3.5 GB) for a slight speedup. Only loadable