mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-04 03:24:43 -05:00
* ci(gallery): have the checksum bot write the model library The checksum bot edited gallery/index.yaml, which is a generated copy of gallery/models/index.yaml. The model library check fails any PR where the copy differs from the generated one, and an edit made to the copy never reaches the source. Point the checker at the library, read the files under .model.files as the library keeps them, and run modelcatalog after the checker so the PR carries both files. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * ci(gallery): have the other index bots write the model library The gallery agent and the variant proposer still read and wrote the generated gallery copy, so their pull requests failed the model library check and their changes never reached the source. The gallery agent now appends library entries to gallery/models/index.yaml, converting its entries with the gallery package and skipping ids the library already has. Its duplicate check reads the same file. The variant proposer decodes the library shape (the body under model:) and splices variants into the model body through a library layout in galleryedit. The anchor refusal goes: the library has no anchors. Its tests use library entries. Both workflows run modelcatalog after the bot and before the pull request step, and use the Go version from go.mod. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
422 lines
14 KiB
Go
422 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
// entryYAML writes one library entry with a single weight file, which is the
|
|
// shape almost every real entry has. Specs that need something else write the
|
|
// YAML out by hand.
|
|
func entryYAML(name, repo, filename, sha string) string {
|
|
return fmt.Sprintf(`- kind: model
|
|
id: %s
|
|
name: %s
|
|
model:
|
|
url: github:mudler/LocalAI/gallery/virtual.yaml@master
|
|
overrides:
|
|
parameters:
|
|
model: %s
|
|
files:
|
|
- filename: %s
|
|
uri: huggingface://%s/%s
|
|
sha256: %s
|
|
`, name, name, filename, filename, repo, filename, sha)
|
|
}
|
|
|
|
func indexOf(entries ...string) *Index {
|
|
ix, err := ParseIndex(strings.Join(entries, ""))
|
|
ExpectWithOffset(1, err).ToNot(HaveOccurred())
|
|
return ix
|
|
}
|
|
|
|
// familyNames flattens a result into "parent <- variant, variant" strings, the
|
|
// form the specs assert against.
|
|
func familyNames(r *Result) []string {
|
|
out := make([]string, 0, len(r.Families))
|
|
for _, f := range r.Families {
|
|
names := make([]string, 0, len(f.Proposals))
|
|
for _, p := range f.Proposals {
|
|
names = append(names, p.Variant)
|
|
}
|
|
out = append(out, f.Parent+" <- "+strings.Join(names, ", "))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func refusalReasons(r *Result) string {
|
|
var b strings.Builder
|
|
for _, ref := range r.Refusals {
|
|
b.WriteString(strings.Join(ref.Members, " + ") + ": " + ref.Reason + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func suppressionReasons(r *Result) string {
|
|
var b strings.Builder
|
|
for _, s := range r.Suppressed {
|
|
b.WriteString(s.String() + "\n")
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
var _ = Describe("Propose", func() {
|
|
Describe("the grouping signals", func() {
|
|
It("groups entries whose names differ only by a quantization marker", func() {
|
|
ix := indexOf(
|
|
entryYAML("foo-model", "acme/foo-GGUF", "foo-model-Q4_K_M.gguf", "aa"),
|
|
entryYAML("foo-model-q8_0", "acme/foo-GGUF", "foo-model-Q8_0.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(ConsistOf("foo-model <- foo-model-q8_0"))
|
|
Expect(r.Families[0].Proposals[0].Evidence.Signals).To(ContainElement(SignalName))
|
|
Expect(r.Families[0].Proposals[0].Evidence.SharedStem).To(Equal("foo-model"))
|
|
Expect(r.Families[0].Proposals[0].Evidence.QuantTokens).To(ContainElements("q4_k_m", "q8_0"))
|
|
})
|
|
|
|
It("groups entries that use the colon config-suffix convention", func() {
|
|
ix := indexOf(
|
|
entryYAML("bar-model", "acme/bar-GGUF", "bar-model-Q4_K_M.gguf", "aa"),
|
|
entryYAML("bar-model:grammar-functioncall", "acme/bar-GGUF", "bar-model-Q4_K_M-grammar.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(ConsistOf("bar-model <- bar-model:grammar-functioncall"))
|
|
Expect(r.Families[0].Proposals[0].Evidence.Signals).To(ContainElement(SignalConfigSuffix))
|
|
})
|
|
|
|
It("groups entries whose own weight file is the same file at another quantization", func() {
|
|
// The names share no stem, so only the filename signal can link
|
|
// these two.
|
|
ix := indexOf(
|
|
entryYAML("omni-cpp", "Serveurperso/Omni-GGUF", "omnivoice-base-Q8_0.gguf", "aa"),
|
|
entryYAML("omni-cpp-hq", "Serveurperso/Omni-GGUF", "omnivoice-base-BF16.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(ConsistOf("omni-cpp <- omni-cpp-hq"))
|
|
ev := r.Families[0].Proposals[0].Evidence
|
|
Expect(ev.Signals).To(ConsistOf(SignalWeightFile))
|
|
Expect(ev.SharedFile).To(Equal("omnivoice-base"))
|
|
Expect(ev.SharedRepo).To(Equal("serveurperso/omni-gguf"))
|
|
})
|
|
|
|
It("does not let a shared auxiliary file link unrelated models", func() {
|
|
// Both entries ship the same text encoder. That is a packaging
|
|
// convention, not evidence of shared weights: this is how an
|
|
// earlier sweep linked four wan-2.1 entries to each other.
|
|
ix := indexOf(`- kind: model
|
|
id: wan-2.1-t2v
|
|
name: wan-2.1-t2v
|
|
model:
|
|
url: u
|
|
files:
|
|
- filename: wan-2.1-t2v-Q4_K_M.gguf
|
|
uri: huggingface://acme/wan/wan-2.1-t2v-Q4_K_M.gguf
|
|
sha256: aa
|
|
- filename: umt5-xxl-encoder-Q8_0.gguf
|
|
uri: huggingface://acme/wan/umt5-xxl-encoder-Q8_0.gguf
|
|
sha256: cc
|
|
`, `- kind: model
|
|
id: z-image-turbo
|
|
name: z-image-turbo
|
|
model:
|
|
url: u
|
|
files:
|
|
- filename: z-image-turbo-Q4_K_M.gguf
|
|
uri: huggingface://acme/wan/z-image-turbo-Q4_K_M.gguf
|
|
sha256: bb
|
|
- filename: umt5-xxl-encoder-Q8_0.gguf
|
|
uri: huggingface://acme/wan/umt5-xxl-encoder-Q8_0.gguf
|
|
sha256: cc
|
|
`)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
})
|
|
|
|
It("does not treat a shared filename in two different repos as evidence", func() {
|
|
// A finetune republished under the base model's filename is the
|
|
// most common way this signal misfires.
|
|
ix := indexOf(
|
|
entryYAML("llama-3.2-3b-instruct", "hugging-quants/Llama-3.2-3B-Instruct-GGUF", "llama-3.2-3b-instruct-q4_k_m.gguf", "aa"),
|
|
entryYAML("llama-3.2-3b-shiro-roleplay", "someone/Shiro-GGUF", "Llama-3.2-3B-Instruct.Q8_0.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
})
|
|
})
|
|
|
|
Describe("what must never be proposed", func() {
|
|
It("does not group different parameter sizes that share a prefix", func() {
|
|
ix := indexOf(
|
|
entryYAML("qwen3-tts-cpp-0.6b-base", "Serveurperso/Qwen3-TTS-GGUF", "qwen3-tts-talker-Q4_K_M.gguf", "aa"),
|
|
entryYAML("qwen3-tts-cpp-1.7b-base", "Serveurperso/Qwen3-TTS-GGUF", "qwen3-tts-talker-Q8_0.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
Expect(suppressionReasons(r)).To(ContainSubstring("different parameter sizes"))
|
|
})
|
|
|
|
It("does not group the Gemma effective sizes", func() {
|
|
ix := indexOf(
|
|
entryYAML("gemma-4-e2b-it", "google/gemma-GGUF", "gemma-4-it-Q4_K_M.gguf", "aa"),
|
|
entryYAML("gemma-4-e4b-it", "google/gemma-GGUF", "gemma-4-it-Q8_0.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
Expect(suppressionReasons(r)).To(ContainSubstring("different parameter sizes"))
|
|
})
|
|
|
|
It("does not group entries with a byte-identical install payload", func() {
|
|
// whisper-1 exists so OpenAI-compatible clients can send that name.
|
|
// Folding it under whisper-base would hide the name they send.
|
|
payload := ` model:
|
|
url: github:mudler/LocalAI/gallery/whisper-base.yaml@master
|
|
overrides:
|
|
parameters:
|
|
model: ggml-whisper-base.bin
|
|
files:
|
|
- filename: ggml-whisper-base.bin
|
|
uri: huggingface://ggerganov/whisper.cpp/ggml-base.bin
|
|
sha256: aa
|
|
`
|
|
ix := indexOf("- kind: model\n id: whisper-base\n"+payload, "- kind: model\n id: whisper-1\n"+payload)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
Expect(r.AliasSkipped).To(HaveLen(1))
|
|
Expect(r.AliasSkipped[0].Reason).To(ContainSubstring("aliases"))
|
|
})
|
|
|
|
DescribeTable("declines the categories the ledger records",
|
|
func(nameA, nameB string, ledgerYAML string) {
|
|
ix := indexOf(
|
|
entryYAML(nameA, "acme/repo", "shared-weights-Q4_K_M.gguf", "aa"),
|
|
entryYAML(nameB, "acme/repo", "shared-weights-Q8_0.gguf", "bb"),
|
|
)
|
|
ledger, err := ParseLedger([]byte(ledgerYAML))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
|
|
// Without the ledger these would be proposed, which is what
|
|
// makes the ledger load bearing rather than decorative.
|
|
Expect(familyNames(Propose(ix, nil))).ToNot(BeEmpty())
|
|
|
|
r := Propose(ix, ledger)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
Expect(r.Suppressed).To(HaveLen(1))
|
|
},
|
|
Entry("a finetune", "base-model", "base-model-abliterated",
|
|
"tokens:\n - {token: abliterated, reason: finetune}\n"),
|
|
Entry("a distill", "base-model", "base-model-distilled",
|
|
"tokens:\n - {token: distilled, reason: distilled}\n"),
|
|
Entry("English-only versus multilingual ASR", "whisper-small", "whisper-small-en",
|
|
"pairs:\n - {parent: whisper-small, variant: whisper-small-en, reason: English-only versus multilingual}\n"),
|
|
Entry("two products sharing a prefix", "vibevoice-cpp", "vibevoice-cpp-asr",
|
|
"pairs:\n - {parent: vibevoice-cpp, variant: vibevoice-cpp-asr, reason: different products}\n"),
|
|
Entry("a per-language release", "kokoros-de", "kokoros-ja",
|
|
"groups:\n - {members: [kokoros, kokoros-de, kokoros-ja], reason: different languages}\n"),
|
|
)
|
|
|
|
It("reports the ledger's reason so its effect stays visible", func() {
|
|
ix := indexOf(
|
|
entryYAML("base-model", "acme/repo", "shared-weights-Q4_K_M.gguf", "aa"),
|
|
entryYAML("base-model-heretic", "acme/repo", "shared-weights-Q8_0.gguf", "bb"),
|
|
)
|
|
ledger, err := ParseLedger([]byte("tokens:\n - {token: heretic, reason: \"finetune, not a re-quantization\"}\n"))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
r := Propose(ix, ledger)
|
|
Expect(suppressionReasons(r)).To(ContainSubstring("finetune, not a re-quantization"))
|
|
Expect(suppressionReasons(r)).To(ContainSubstring(`token "heretic"`))
|
|
})
|
|
})
|
|
|
|
Describe("parent selection", func() {
|
|
It("picks the bare-named entry when one exists", func() {
|
|
ix := indexOf(
|
|
entryYAML("base-model-q8_0", "acme/repo", "base-model-Q8_0.gguf", "aa"),
|
|
entryYAML("base-model", "acme/repo", "base-model-Q4_K_M.gguf", "bb"),
|
|
entryYAML("base-model-f16", "acme/repo", "base-model-f16.gguf", "cc"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(ConsistOf("base-model <- base-model-f16, base-model-q8_0"))
|
|
})
|
|
|
|
It("picks the smallest build when no entry is bare-named", func() {
|
|
ix := indexOf(
|
|
entryYAML("ced-base-f16", "acme/repo", "ced-base-f16.gguf", "aa"),
|
|
entryYAML("ced-base-q8", "acme/repo", "ced-base-Q8_0.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(ConsistOf("ced-base-q8 <- ced-base-f16"))
|
|
})
|
|
|
|
It("judges the smallest build by the quantization in the model filename, not the name", func() {
|
|
// The names carry no marker at all; only the filenames say which
|
|
// build is which.
|
|
ix := indexOf(
|
|
entryYAML("thing-hq", "acme/repo", "thing-weights-BF16.gguf", "aa"),
|
|
entryYAML("thing-lite", "acme/repo", "thing-weights-Q4_K_M.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(ConsistOf("thing-lite <- thing-hq"))
|
|
})
|
|
})
|
|
|
|
Describe("the rules a proposal has to respect", func() {
|
|
It("refuses to nest: a target that already offers variants of its own", func() {
|
|
ix := indexOf(
|
|
entryYAML("nest-model", "acme/repo", "nest-model-Q4_K_M.gguf", "aa"),
|
|
`- kind: model
|
|
id: nest-model-q8_0
|
|
name: nest-model-q8_0
|
|
model:
|
|
url: u
|
|
variants:
|
|
- model: nest-model-q8_0-mtp
|
|
overrides:
|
|
parameters:
|
|
model: nest-model-Q8_0.gguf
|
|
files:
|
|
- filename: nest-model-Q8_0.gguf
|
|
uri: huggingface://acme/repo/nest-model-Q8_0.gguf
|
|
sha256: bb
|
|
`,
|
|
entryYAML("nest-model-q8_0-mtp", "other/repo", "nest-model-mtp.gguf", "cc"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
Expect(refusalReasons(r)).To(ContainSubstring("already offers variants of its own"))
|
|
})
|
|
|
|
It("refuses to nest: a parent that is already somebody else's variant", func() {
|
|
ix := indexOf(
|
|
`- kind: model
|
|
id: outer
|
|
name: outer
|
|
model:
|
|
url: u
|
|
variants:
|
|
- model: middle
|
|
overrides:
|
|
parameters:
|
|
model: outer-Q4_K_M.gguf
|
|
files:
|
|
- filename: outer-Q4_K_M.gguf
|
|
uri: huggingface://acme/repo/outer-Q4_K_M.gguf
|
|
sha256: aa
|
|
`,
|
|
entryYAML("middle", "acme/other", "middle-Q4_K_M.gguf", "bb"),
|
|
entryYAML("middle-q8_0", "acme/other", "middle-Q8_0.gguf", "cc"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
Expect(refusalReasons(r)).To(ContainSubstring("would nest variants"))
|
|
})
|
|
|
|
It("refuses to let two parents claim one target", func() {
|
|
ix := indexOf(
|
|
`- kind: model
|
|
id: claimant
|
|
name: claimant
|
|
model:
|
|
url: u
|
|
variants:
|
|
- model: contested-q8_0
|
|
overrides:
|
|
parameters:
|
|
model: claimant-Q4_K_M.gguf
|
|
files:
|
|
- filename: claimant-Q4_K_M.gguf
|
|
uri: huggingface://acme/repo/claimant-Q4_K_M.gguf
|
|
sha256: aa
|
|
`,
|
|
entryYAML("contested", "acme/other", "contested-Q4_K_M.gguf", "bb"),
|
|
entryYAML("contested-q8_0", "acme/other", "contested-Q8_0.gguf", "cc"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
Expect(refusalReasons(r)).To(ContainSubstring("already a variant of"))
|
|
})
|
|
|
|
It("refuses a target that is not independently installable", func() {
|
|
ix := indexOf(
|
|
entryYAML("stub-model", "acme/repo", "stub-model-Q4_K_M.gguf", "aa"),
|
|
"- kind: model\n id: stub-model-q8_0\n name: stub-model-q8_0\n description: a stanza nobody finished\n model: {}\n",
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
Expect(refusalReasons(r)).To(ContainSubstring("not independently installable"))
|
|
})
|
|
|
|
It("refuses an entry whose name is not unique in the gallery", func() {
|
|
ix := indexOf(
|
|
entryYAML("twin", "acme/repo", "twin-Q4_K_M.gguf", "aa"),
|
|
entryYAML("twin", "acme/repo", "twin-Q4_K_M.gguf", "aa"),
|
|
entryYAML("twin-q8_0", "acme/repo", "twin-Q8_0.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(BeEmpty())
|
|
Expect(refusalReasons(r)).To(ContainSubstring("appears more than once"))
|
|
})
|
|
|
|
It("says nothing about a pair that is already grouped", func() {
|
|
ix := indexOf(
|
|
`- kind: model
|
|
id: settled
|
|
name: settled
|
|
model:
|
|
url: u
|
|
variants:
|
|
- model: settled-q8_0
|
|
overrides:
|
|
parameters:
|
|
model: settled-Q4_K_M.gguf
|
|
files:
|
|
- filename: settled-Q4_K_M.gguf
|
|
uri: huggingface://acme/repo/settled-Q4_K_M.gguf
|
|
sha256: aa
|
|
`,
|
|
entryYAML("settled-q8_0", "acme/repo", "settled-Q8_0.gguf", "bb"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(r.HasProposals()).To(BeFalse())
|
|
Expect(r.Refusals).To(BeEmpty())
|
|
Expect(r.Suppressed).To(BeEmpty())
|
|
})
|
|
|
|
It("adds only the missing members to a family that already exists", func() {
|
|
ix := indexOf(
|
|
`- kind: model
|
|
id: partial
|
|
name: partial
|
|
model:
|
|
url: u
|
|
variants:
|
|
- model: partial-q8_0
|
|
overrides:
|
|
parameters:
|
|
model: partial-Q4_K_M.gguf
|
|
files:
|
|
- filename: partial-Q4_K_M.gguf
|
|
uri: huggingface://acme/repo/partial-Q4_K_M.gguf
|
|
sha256: aa
|
|
`,
|
|
entryYAML("partial-q8_0", "acme/repo", "partial-Q8_0.gguf", "bb"),
|
|
entryYAML("partial-f16", "acme/repo", "partial-f16.gguf", "cc"),
|
|
)
|
|
r := Propose(ix, nil)
|
|
Expect(familyNames(r)).To(ConsistOf("partial <- partial-f16"))
|
|
})
|
|
})
|
|
|
|
It("does not modify the index it was given", func() {
|
|
text := entryYAML("foo-model", "acme/foo-GGUF", "foo-model-Q4_K_M.gguf", "aa") +
|
|
entryYAML("foo-model-q8_0", "acme/foo-GGUF", "foo-model-Q8_0.gguf", "bb")
|
|
ix, err := ParseIndex(text)
|
|
Expect(err).ToNot(HaveOccurred())
|
|
before := strings.Join(ix.Lines, "\n")
|
|
Propose(ix, nil)
|
|
Expect(strings.Join(ix.Lines, "\n")).To(Equal(before))
|
|
})
|
|
})
|