diff --git a/.agents/adding-gallery-models.md b/.agents/adding-gallery-models.md index 9e44b0a11..93563c762 100644 --- a/.agents/adding-gallery-models.md +++ b/.agents/adding-gallery-models.md @@ -119,10 +119,11 @@ Rules: and installs the largest survivor, falling back to the declaring entry's own build. Sizes are measured live from the weights and cached, so nothing has to be written down. -- `min_memory` on a single variant (e.g. `min_memory: 20GiB`) overrides the - measured size and suppresses the measurement for that variant. Use it only - when you have measured a real load and know the estimate is wrong; most - variants should not carry it. +- A variant is nothing but a name; there is no per-variant memory field. When + the measured size for a build is wrong, correct it on the referenced entry by + setting that entry's own `size:` (e.g. `size: "20GiB"`). The estimator prefers + a declared size over its own guesswork, so the fix applies everywhere the size + is shown or compared rather than only to variant selection. Users can override the automatic choice with `variant` on `POST /models/apply`, `local-ai models install --variant`, or the `install_model` MCP tool. See diff --git a/core/gallery/describe_variants.go b/core/gallery/describe_variants.go index a44864e00..726308909 100644 --- a/core/gallery/describe_variants.go +++ b/core/gallery/describe_variants.go @@ -63,10 +63,7 @@ func DescribeVariants(models []*GalleryModel, entry *GalleryModel, env ResolveEn views := make([]VariantView, 0, len(options)) for _, o := range options { - memory, known, err := o.EffectiveMemory() - if err != nil { - return nil, err - } + memory, known := o.EffectiveMemory() view := VariantView{ Model: o.Variant.Model, Backend: o.Backend, diff --git a/core/gallery/describe_variants_test.go b/core/gallery/describe_variants_test.go index 4831aff15..0dbe35f39 100644 --- a/core/gallery/describe_variants_test.go +++ b/core/gallery/describe_variants_test.go @@ -124,17 +124,6 @@ var _ = Describe("DescribeVariants", func() { Expect(byName(view, "qwen3-8b-gguf-q8").MemoryBytes).To(Equal(gib(9))) }) - It("lets an authored min_memory win over the probe and skips that probe", func() { - base.Variants = []gallery.Variant{{Model: "qwen3-8b-vllm-awq", MinMemory: "20GiB"}} - - view, err := gallery.DescribeVariants(models, base, probing(gib(24), map[string]uint64{ - "qwen3-8b-vllm-awq": gib(2), - })) - Expect(err).ToNot(HaveOccurred()) - Expect(byName(view, "qwen3-8b-vllm-awq").MemoryBytes).To(Equal(gib(20))) - Expect(probed).ToNot(ContainElement("qwen3-8b-vllm-awq")) - }) - It("reports a size it could not determine as unknown rather than as free", func() { // Zero on the wire means unknown. Rendering it as a zero-byte model // would advertise the largest download on offer as costless. diff --git a/core/gallery/models.go b/core/gallery/models.go index 9cc492125..d1fbae9b6 100644 --- a/core/gallery/models.go +++ b/core/gallery/models.go @@ -105,9 +105,7 @@ func variantOptions(models []*GalleryModel, entry *GalleryModel, env ResolveEnv) } option := VariantOption{Variant: v, Backend: target.Backend} - // An authored figure short circuits the probe: the author already - // answered the question the probe would spend a round trip asking. - if v.MinMemory == "" && env.ProbeMemory != nil { + if env.ProbeMemory != nil { option.ProbedMemory = env.ProbeMemory(target) } options = append(options, option) @@ -206,7 +204,7 @@ func ResolveVariant(models []*GalleryModel, entry *GalleryModel, env ResolveEnv, // checks, but a silent bypass makes a later out-of-memory failure // impossible to trace back to the pin, so it is recorded loudly here. if pin != "" { - if need, known, verr := selected.EffectiveMemory(); verr == nil && known && env.AvailableMemory < need { + if need, known := selected.EffectiveMemory(); known && env.AvailableMemory < need { xlog.Warn("Pinned model variant declares more memory than this system reports; installing anyway because the pin overrides hardware resolution", "model", entry.Name, "variant", selected.Variant.Model, "required_memory", need, "available_memory", env.AvailableMemory) } diff --git a/core/gallery/resolve_variant.go b/core/gallery/resolve_variant.go index cb111bc61..e63bbffb5 100644 --- a/core/gallery/resolve_variant.go +++ b/core/gallery/resolve_variant.go @@ -31,9 +31,10 @@ type VariantOption struct { // must stay installable on every host and for every client. IsBase bool // ProbedMemory is the footprint measured live from the referenced entry's - // weights, in bytes. It is only consulted when the variant declares no - // min_memory of its own, because a human who measured a real load knows - // more than a pre-download estimate does. + // weights, in bytes. It is the only source of a variant's size. An author + // who needs to correct a bad estimate sets `size:` on the referenced entry, + // which the estimator behind the probe already prefers over its own + // guesswork, so the correction lands everywhere the size is used. // // Zero means the probe could not determine a size. That is an unknown, not // a zero requirement: a probe that cannot reach the network must never be @@ -42,17 +43,12 @@ type VariantOption struct { } // EffectiveMemory returns this option's memory requirement in bytes and whether -// one is known at all: the authored figure when there is one, else the live -// probe result, else nothing. -func (o VariantOption) EffectiveMemory() (uint64, bool, error) { - size, known, err := o.Variant.AuthoredMinMemory() - if err != nil || known { - return size, known, err - } +// one is known at all. +func (o VariantOption) EffectiveMemory() (uint64, bool) { if o.ProbedMemory > 0 { - return o.ProbedMemory, true, nil + return o.ProbedMemory, true } - return 0, false, nil + return 0, false } // ResolveEnv describes the host a variant is selected for. @@ -69,13 +65,12 @@ type ResolveEnv struct { // caller with no view of the hardware. BackendCompatible func(backend string) bool // ProbeMemory measures how much memory a referenced gallery entry needs, - // without downloading it. It is consulted only for variants that declare no - // min_memory, and a zero result means "could not tell", never "needs - // nothing". + // without downloading it. A zero result means "could not tell", never + // "needs nothing". // // It is a func field rather than a live network handle so specs can pin an // exact size, or an exact failure, without reaching the internet. A nil func - // leaves every unauthored variant unknown, which selection already handles. + // leaves every variant unknown, which selection already handles. // // SelectVariant never calls this: the install layer resolves every size into // VariantOption.ProbedMemory first, so the selector stays pure. @@ -149,10 +144,7 @@ func SelectVariant(options []VariantOption, env ResolveEnv, pin string) (Variant continue } - memory, known, err := o.EffectiveMemory() - if err != nil { - return VariantSelection{}, err - } + memory, known := o.EffectiveMemory() if known && memory > env.AvailableMemory { reasons = append(reasons, fmt.Sprintf("%s needs %s of memory", o.Variant.Model, humanBytes(memory))) continue diff --git a/core/gallery/resolve_variant_test.go b/core/gallery/resolve_variant_test.go index 49fcb3468..3c3a09474 100644 --- a/core/gallery/resolve_variant_test.go +++ b/core/gallery/resolve_variant_test.go @@ -8,69 +8,49 @@ import ( ) var _ = Describe("VariantOption.EffectiveMemory", func() { - It("reports no requirement when nothing is authored and nothing was probed", func() { + It("reports no requirement when nothing was probed", func() { o := gallery.VariantOption{Variant: gallery.Variant{Model: "x"}} - size, known, err := o.EffectiveMemory() - Expect(err).ToNot(HaveOccurred()) + size, known := o.EffectiveMemory() Expect(known).To(BeFalse()) Expect(size).To(Equal(uint64(0))) }) - It("uses the probed size when nothing is authored", func() { + It("uses the probed size", func() { o := gallery.VariantOption{Variant: gallery.Variant{Model: "x"}, ProbedMemory: 6 * 1024 * 1024 * 1024} - size, known, err := o.EffectiveMemory() - Expect(err).ToNot(HaveOccurred()) + size, known := o.EffectiveMemory() Expect(known).To(BeTrue()) Expect(size).To(Equal(uint64(6 * 1024 * 1024 * 1024))) }) - It("lets an authored figure win over a probed one", func() { - // A human who measured a real load knows more than a pre-download - // estimate does, which is the entire reason min_memory survives. - o := gallery.VariantOption{ - Variant: gallery.Variant{Model: "x", MinMemory: "20GiB"}, - ProbedMemory: 6 * 1024 * 1024 * 1024, - } - size, known, err := o.EffectiveMemory() - Expect(err).ToNot(HaveOccurred()) - Expect(known).To(BeTrue()) - Expect(size).To(Equal(uint64(20 * 1024 * 1024 * 1024))) - }) - It("treats a failed probe as unknown rather than as a zero requirement", func() { // A probe that could not reach the network reports 0. Reading that as // "needs nothing" would make an unreachable host look like the perfect // fit and hand the user the largest download on offer. o := gallery.VariantOption{Variant: gallery.Variant{Model: "x"}, ProbedMemory: 0} - _, known, err := o.EffectiveMemory() - Expect(err).ToNot(HaveOccurred()) + _, known := o.EffectiveMemory() Expect(known).To(BeFalse()) }) - - It("errors on an unparseable authored figure rather than silently ignoring it", func() { - o := gallery.VariantOption{Variant: gallery.Variant{Model: "x", MinMemory: "twenty gigs"}} - _, _, err := o.EffectiveMemory() - Expect(err).To(HaveOccurred()) - }) }) var _ = Describe("SelectVariant", func() { gib := func(n uint64) uint64 { return n * 1024 * 1024 * 1024 } - option := func(model, backend, minMemory string) gallery.VariantOption { + // Every size here is a probed one, because the probe is now the only source + // a variant's footprint can come from. A zero stands for the probe having + // been unable to tell, which is an unknown rather than a zero requirement. + option := func(model, backend string, probed uint64) gallery.VariantOption { return gallery.VariantOption{ - Variant: gallery.Variant{Model: model, MinMemory: minMemory}, - Backend: backend, + Variant: gallery.Variant{Model: model}, + Backend: backend, + ProbedMemory: probed, } } - // The base entry declares no memory requirement of its own, so a base - // option's size can only ever come from a probe. It is exempt from every - // filter regardless, which the fallback specs below pin down. + // The base is exempt from every filter, which the fallback specs below pin + // down; its size is carried only so ranking has nothing special to do. base := func(model string, probed uint64) gallery.VariantOption { - o := option(model, "llama-cpp", "") + o := option(model, "llama-cpp", probed) o.IsBase = true - o.ProbedMemory = probed return o } @@ -85,8 +65,8 @@ var _ = Describe("SelectVariant", func() { // The MLX build is both the largest and the only thing that would // otherwise win, so nothing but the backend gate can reject it. options := []gallery.VariantOption{ - option("m-mlx-8bit", "mlx", "24GiB"), - option("m-gguf-q8", "llama-cpp", "12GiB"), + option("m-mlx-8bit", "mlx", gib(24)), + option("m-gguf-q8", "llama-cpp", gib(12)), base("m-gguf-q4", gib(6)), } @@ -102,8 +82,8 @@ var _ = Describe("SelectVariant", func() { // The mirror image of the spec above, so the rejection is proven to // come from the host and not from something intrinsic to the entry. options := []gallery.VariantOption{ - option("m-mlx-8bit", "mlx", "24GiB"), - option("m-gguf-q8", "llama-cpp", "12GiB"), + option("m-mlx-8bit", "mlx", gib(24)), + option("m-gguf-q8", "llama-cpp", gib(12)), base("m-gguf-q4", gib(6)), } @@ -116,7 +96,7 @@ var _ = Describe("SelectVariant", func() { }) It("treats every backend as runnable when the host cannot be inspected", func() { - options := []gallery.VariantOption{option("m-mlx-8bit", "mlx", "24GiB"), base("m-gguf-q4", gib(6))} + options := []gallery.VariantOption{option("m-mlx-8bit", "mlx", gib(24)), base("m-gguf-q4", gib(6))} selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(80)}, "") Expect(err).ToNot(HaveOccurred()) @@ -129,9 +109,9 @@ var _ = Describe("SelectVariant", func() { // Authored smallest-first, so first-match would take m-q4 and any // ranking that ignores size would too. options := []gallery.VariantOption{ - option("m-q4", "llama-cpp", "6GiB"), - option("m-q8", "llama-cpp", "12GiB"), - option("m-f16", "llama-cpp", "24GiB"), + option("m-q4", "llama-cpp", gib(6)), + option("m-q8", "llama-cpp", gib(12)), + option("m-f16", "llama-cpp", gib(24)), base("m-base", gib(2)), } @@ -145,9 +125,9 @@ var _ = Describe("SelectVariant", func() { // Same set, authored largest-first. Order must make no difference at // all, which is the entire point of dropping ordered first-match. options := []gallery.VariantOption{ - option("m-f16", "llama-cpp", "24GiB"), - option("m-q8", "llama-cpp", "12GiB"), - option("m-q4", "llama-cpp", "6GiB"), + option("m-f16", "llama-cpp", gib(24)), + option("m-q8", "llama-cpp", gib(12)), + option("m-q4", "llama-cpp", gib(6)), base("m-base", gib(2)), } @@ -161,8 +141,8 @@ var _ = Describe("SelectVariant", func() { // nothing proves it does not fit, but it must never displace a // variant that is known to fit. options := []gallery.VariantOption{ - option("m-unknown", "llama-cpp", ""), - option("m-q8", "llama-cpp", "12GiB"), + option("m-unknown", "llama-cpp", 0), + option("m-q8", "llama-cpp", gib(12)), base("m-base", gib(2)), } @@ -173,7 +153,7 @@ var _ = Describe("SelectVariant", func() { It("keeps a variant of unknown size rather than dropping it", func() { options := []gallery.VariantOption{ - option("m-unknown", "llama-cpp", ""), + option("m-unknown", "llama-cpp", 0), base("m-base", gib(2)), } @@ -183,19 +163,13 @@ var _ = Describe("SelectVariant", func() { Expect(selection.FellBackToBase).To(BeFalse()) }) - It("ranks and filters a probed size exactly as an authored one", func() { - // Nothing here declares min_memory, so every figure came from the - // live probe. The probe is the primary source now and authoring the - // exception, so it has to drive both the filter and the ranking. - probed := func(model string, size uint64) gallery.VariantOption { - o := option(model, "llama-cpp", "") - o.ProbedMemory = size - return o - } + It("reports why a probed size that does not fit was rejected", func() { + // Ranking and filtering both run off the probe, so a rejection has to + // be traceable back to the figure the probe returned. options := []gallery.VariantOption{ - probed("m-q4", gib(6)), - probed("m-f16", gib(24)), - probed("m-q8", gib(12)), + option("m-q4", "llama-cpp", gib(6)), + option("m-f16", "llama-cpp", gib(24)), + option("m-q8", "llama-cpp", gib(12)), base("m-base", gib(2)), } @@ -206,7 +180,7 @@ var _ = Describe("SelectVariant", func() { }) It("admits a variant needing exactly the memory available", func() { - options := []gallery.VariantOption{option("m-q8", "llama-cpp", "12GiB"), base("m-base", gib(2))} + options := []gallery.VariantOption{option("m-q8", "llama-cpp", gib(12)), base("m-base", gib(2))} selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(12)}, "") Expect(err).ToNot(HaveOccurred()) @@ -217,8 +191,8 @@ var _ = Describe("SelectVariant", func() { Describe("falling back to the base", func() { It("selects the base when nothing else fits", func() { options := []gallery.VariantOption{ - option("m-q8", "llama-cpp", "12GiB"), - option("m-f16", "llama-cpp", "24GiB"), + option("m-q8", "llama-cpp", gib(12)), + option("m-f16", "llama-cpp", gib(24)), base("m-base", gib(2)), } @@ -234,7 +208,7 @@ var _ = Describe("SelectVariant", func() { // The base is exempt from the memory filter, not merely favoured by // it: there is nothing below it, so refusing here would make an entry // every older client installs fine uninstallable on newer ones. - options := []gallery.VariantOption{option("m-q8", "llama-cpp", "12GiB"), base("m-base", gib(2))} + options := []gallery.VariantOption{option("m-q8", "llama-cpp", gib(12)), base("m-base", gib(2))} selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: 0}, "") Expect(err).ToNot(HaveOccurred()) @@ -243,8 +217,8 @@ var _ = Describe("SelectVariant", func() { It("explains why each variant was rejected", func() { options := []gallery.VariantOption{ - option("m-mlx", "mlx", "8GiB"), - option("m-f16", "llama-cpp", "24GiB"), + option("m-mlx", "mlx", gib(8)), + option("m-f16", "llama-cpp", gib(24)), base("m-base", gib(2)), } @@ -258,7 +232,7 @@ var _ = Describe("SelectVariant", func() { }) It("errors when the caller supplies no base at all", func() { - options := []gallery.VariantOption{option("m-f16", "llama-cpp", "24GiB")} + options := []gallery.VariantOption{option("m-f16", "llama-cpp", gib(24))} _, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(4)}, "") Expect(err).To(MatchError(gallery.ErrNoVariantMatch)) @@ -268,7 +242,7 @@ var _ = Describe("SelectVariant", func() { Describe("explicit selection", func() { It("honors a pin the hardware would never have chosen", func() { options := []gallery.VariantOption{ - option("m-mlx", "mlx", "64GiB"), + option("m-mlx", "mlx", gib(64)), base("m-base", gib(2)), } @@ -281,7 +255,7 @@ var _ = Describe("SelectVariant", func() { }) It("honors a pin naming the base, declining an upgrade that fits", func() { - options := []gallery.VariantOption{option("m-f16", "llama-cpp", "8GiB"), base("m-base", gib(2))} + options := []gallery.VariantOption{option("m-f16", "llama-cpp", gib(8)), base("m-base", gib(2))} selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(64)}, "m-base") Expect(err).ToNot(HaveOccurred()) @@ -289,7 +263,7 @@ var _ = Describe("SelectVariant", func() { }) It("matches a pin case-insensitively", func() { - options := []gallery.VariantOption{option("m-f16", "llama-cpp", "8GiB"), base("m-base", gib(2))} + options := []gallery.VariantOption{option("m-f16", "llama-cpp", gib(8)), base("m-base", gib(2))} selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(64)}, "M-F16") Expect(err).ToNot(HaveOccurred()) @@ -297,19 +271,11 @@ var _ = Describe("SelectVariant", func() { }) It("fails loudly when the pin names nothing in the list", func() { - options := []gallery.VariantOption{option("m-f16", "llama-cpp", "8GiB"), base("m-base", gib(2))} + options := []gallery.VariantOption{option("m-f16", "llama-cpp", gib(8)), base("m-base", gib(2))} _, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(64)}, "m-gone") Expect(err).To(MatchError(gallery.ErrPinNotFound)) Expect(err.Error()).To(ContainSubstring("m-gone")) }) }) - - It("propagates an unparseable figure instead of treating it as unconstrained", func() { - options := []gallery.VariantOption{option("bad", "llama-cpp", "lots"), base("m-base", gib(2))} - - _, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(8)}, "") - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("bad")) - }) }) diff --git a/core/gallery/variant.go b/core/gallery/variant.go index 35ca8bfb7..ad7ff0932 100644 --- a/core/gallery/variant.go +++ b/core/gallery/variant.go @@ -1,44 +1,18 @@ package gallery -import ( - "fmt" - - "github.com/mudler/LocalAI/pkg/vram" -) - // Variant is one option in a gallery entry's variant list. It references an // existing gallery entry by name, and that is all an author has to write. // // Authored order carries no meaning. Selection filters out what this host // cannot run and then ranks what is left, so hardware knowledge lives in the // selector rather than being pushed onto whoever edits the gallery. +// +// There is deliberately no authored memory figure here. When the live probe +// misreads a variant's footprint, the fix is the referenced entry's own `size:` +// field: the estimator already prefers a declared size over its own guesswork, +// so correcting it there fixes the figure for every consumer rather than only +// for this one selection pass. type Variant struct { // Model is the name of a gallery entry that declares no variants of its own. Model string `json:"model" yaml:"model"` - // MinMemory is the authored memory requirement (e.g. "20GiB"). It exists to - // override the size the installer probes live from the model's weights when - // that figure is known to be wrong; most variants should not need it. - // - // One number covers both VRAM and system RAM. A model's footprint is - // roughly the same wherever it lives, and the selector compares this against - // whichever of the two this host will actually use, so splitting it in two - // would only invite the pair to disagree. - MinMemory string `json:"min_memory,omitempty" yaml:"min_memory,omitempty"` -} - -// AuthoredMinMemory returns the hand-written memory requirement in bytes and -// whether one was written at all. -// -// An absent requirement is not a zero requirement. Callers must not read the -// returned 0 as "fits anywhere"; it means the author said nothing, and the -// figure has to come from a live probe instead. -func (v Variant) AuthoredMinMemory() (uint64, bool, error) { - if v.MinMemory == "" { - return 0, false, nil - } - size, err := vram.ParseSizeString(v.MinMemory) - if err != nil { - return 0, false, fmt.Errorf("variant %q has an unparseable min_memory %q: %w", v.Model, v.MinMemory, err) - } - return size, true, nil } diff --git a/core/gallery/variants_install_test.go b/core/gallery/variants_install_test.go index 8bf275f33..e67faaecf 100644 --- a/core/gallery/variants_install_test.go +++ b/core/gallery/variants_install_test.go @@ -36,19 +36,16 @@ url: "github:example/repo/qwen3.6-27b.yaml@master" variants: - model: qwen3.6-27b-mlx-8bit - model: qwen3.6-27b-gguf-q8 - min_memory: 28GiB `), &m) Expect(err).ToNot(HaveOccurred()) Expect(m.Name).To(Equal("qwen3.6-27b")) Expect(m.URL).To(Equal("github:example/repo/qwen3.6-27b.yaml@master")) Expect(m.HasVariants()).To(BeTrue()) Expect(m.Variants).To(HaveLen(2)) - // The first variant is nothing but a name, which is the shape authoring - // is meant to reach for. + // A variant is nothing but a name, which is the whole of the authoring + // surface. Expect(m.Variants[0].Model).To(Equal("qwen3.6-27b-mlx-8bit")) - Expect(m.Variants[0].MinMemory).To(BeEmpty()) Expect(m.Variants[1].Model).To(Equal("qwen3.6-27b-gguf-q8")) - Expect(m.Variants[1].MinMemory).To(Equal("28GiB")) }) }) @@ -79,12 +76,24 @@ var _ = Describe("ResolveVariant", func() { base = newModel("qwen3-8b-gguf-q4", "file://gguf.yaml", "Qwen3 8B Q4", "qwen.png") base.Backend = "llama-cpp" base.Tags = []string{"llm"} - base.Variants = []gallery.Variant{{Model: "qwen3-8b-vllm-awq", MinMemory: "20GiB"}} + base.Variants = []gallery.Variant{{Model: "qwen3-8b-vllm-awq"}} models = []*gallery.GalleryModel{upgrade, base} }) + // The AWQ build measures 20GiB, so the specs below vary only the host and + // read off which payload resolution lands on. The probe is injected rather + // than performed, so nothing here reaches the network. env := func(memory uint64) gallery.ResolveEnv { - return gallery.ResolveEnv{AvailableMemory: memory, BackendCompatible: runsEverything} + return gallery.ResolveEnv{ + AvailableMemory: memory, + BackendCompatible: runsEverything, + ProbeMemory: func(target *gallery.GalleryModel) uint64 { + if target.Name == "qwen3-8b-vllm-awq" { + return 20 * 1024 * 1024 * 1024 + } + return 0 + }, + } } It("installs a fitting variant's payload under the entry's name", func() { @@ -141,12 +150,6 @@ var _ = Describe("ResolveVariant", func() { return e } - BeforeEach(func() { - // No authored figure anywhere, so every size below comes from the - // probe. This is the shape authoring is meant to reach for. - base.Variants = []gallery.Variant{{Model: "qwen3-8b-vllm-awq"}} - }) - It("selects a variant the probe shows to fit", func() { resolved, variant, err := gallery.ResolveVariant(models, base, probing(gib(24), map[string]uint64{"qwen3-8b-vllm-awq": gib(20)}), "") @@ -188,23 +191,6 @@ var _ = Describe("ResolveVariant", func() { Expect(variant.Model).To(Equal("qwen3-8b-gguf-q4")) Expect(resolved.URL).To(Equal("file://gguf.yaml")) }) - - It("does not probe a variant that declares its own min_memory", func() { - // Probing costs a network round trip, so an authored figure has to - // suppress it outright rather than merely outrank it. - base.Variants = []gallery.Variant{{Model: "qwen3-8b-vllm-awq", MinMemory: "20GiB"}} - probed := []string{} - e := env(gib(24)) - e.ProbeMemory = func(target *gallery.GalleryModel) uint64 { - probed = append(probed, target.Name) - return gib(80) - } - - _, variant, err := gallery.ResolveVariant(models, base, e, "") - Expect(err).ToNot(HaveOccurred()) - Expect(probed).To(BeEmpty()) - Expect(variant.Model).To(Equal("qwen3-8b-vllm-awq")) - }) }) It("presents the entry's metadata, not the variant's", func() { @@ -350,6 +336,16 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { return m } + // sizedEntry declares its footprint through the entry's own size:, which is + // the only way an author influences a variant's measured size. The estimator + // prefers a declared size over its own guesswork and parses it locally, so + // these specs pin an exact figure without touching the network. + sizedEntry := func(name, backend, size string) gallery.GalleryModel { + m := entry(name, backend) + m.Size = size + return m + } + // urlEntry describes an entry through a url rather than an inline // config_file. The distinction matters for the recorded name: the url branch // reads a name out of the fetched config, and that name is the referenced @@ -375,9 +371,10 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { } // withVariants attaches alternative builds to an otherwise ordinary entry. - // The figures are absolute rather than relative to the host: "0GiB" always - // fits and "10000GiB" never does, so these specs assert on selection rather - // than on whatever memory the machine running them happens to have. + // Where a spec needs a definite size it declares it on the referenced entry + // with sizedEntry, in absolute terms rather than relative to the host, so + // these specs assert on selection rather than on whatever memory the machine + // running them happens to have. withVariants := func(m gallery.GalleryModel, variants ...gallery.Variant) gallery.GalleryModel { m.Variants = variants return m @@ -410,8 +407,8 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { It("installs the entry's own payload when no variant fits the host", func() { newGallery( withVariants(entry("qwen3-8b-q4", "base-backend"), - gallery.Variant{Model: "qwen3-8b-q8", MinMemory: "10000GiB"}), - entry("qwen3-8b-q8", "upgrade-backend"), + gallery.Variant{Model: "qwen3-8b-q8"}), + sizedEntry("qwen3-8b-q8", "upgrade-backend", "10000GiB"), ) // No machine clears 10000GiB, so this asserts the base is the last @@ -423,8 +420,8 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { It("installs a fitting variant's payload under the entry's own name", func() { newGallery( withVariants(entry("qwen3-8b-q4", "base-backend"), - gallery.Variant{Model: "qwen3-8b-q8", MinMemory: "0GiB"}), - entry("qwen3-8b-q8", "upgrade-backend"), + gallery.Variant{Model: "qwen3-8b-q8"}), + sizedEntry("qwen3-8b-q8", "upgrade-backend", "16MiB"), ) Expect(install("qwen3-8b-q4", gallery.GalleryModel{})).To(Succeed()) @@ -454,11 +451,11 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { // would take the small one. newGallery( withVariants(entry("qwen3-8b-q4", "base-backend"), - gallery.Variant{Model: "qwen3-8b-small", MinMemory: "0GiB"}, - gallery.Variant{Model: "qwen3-8b-large", MinMemory: "1KiB"}, + gallery.Variant{Model: "qwen3-8b-small"}, + gallery.Variant{Model: "qwen3-8b-large"}, ), - entry("qwen3-8b-small", "small-backend"), - entry("qwen3-8b-large", "large-backend"), + sizedEntry("qwen3-8b-small", "small-backend", "16MiB"), + sizedEntry("qwen3-8b-large", "large-backend", "256MiB"), ) Expect(install("qwen3-8b-q4", gallery.GalleryModel{})).To(Succeed()) @@ -474,7 +471,7 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { // the only thing that can send this to the base. newGallery( withVariants(entry("qwen3-8b-q4", "base-backend"), - gallery.Variant{Model: "qwen3-8b-mlx", MinMemory: "0GiB"}), + gallery.Variant{Model: "qwen3-8b-mlx"}), entry("qwen3-8b-mlx", "mlx"), ) @@ -489,7 +486,7 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { // anything reads the record back. newGallery( withVariants(urlEntry("qwen3-8b-q4", "base-backend"), - gallery.Variant{Model: "qwen3-8b-q8", MinMemory: "0GiB"}), + gallery.Variant{Model: "qwen3-8b-q8"}), urlEntry("qwen3-8b-q8", "upgrade-backend"), ) @@ -508,7 +505,7 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { It("refuses a variant name the entry does not declare, naming what was asked for", func() { newGallery( withVariants(entry("qwen3-8b-q4", "base-backend"), - gallery.Variant{Model: "qwen3-8b-q8", MinMemory: "0GiB"}), + gallery.Variant{Model: "qwen3-8b-q8"}), entry("qwen3-8b-q8", "upgrade-backend"), ) @@ -538,7 +535,7 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { It("records a pin and honors it on a plain reinstall", func() { newGallery( withVariants(entry("qwen3-8b-q4", "base-backend"), - gallery.Variant{Model: "qwen3-8b-q8", MinMemory: "0GiB"}), + gallery.Variant{Model: "qwen3-8b-q8"}), entry("qwen3-8b-q8", "upgrade-backend"), ) @@ -563,7 +560,7 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { It("honors a pin recorded under a custom install name", func() { newGallery( withVariants(entry("qwen3-8b-q4", "base-backend"), - gallery.Variant{Model: "qwen3-8b-q8", MinMemory: "0GiB"}), + gallery.Variant{Model: "qwen3-8b-q8"}), entry("qwen3-8b-q8", "upgrade-backend"), ) @@ -586,7 +583,7 @@ var _ = Describe("InstallModelFromGallery with variant entries", func() { It("writes each declared url only once into the persisted gallery file", func() { base := withVariants(entry("qwen3-8b-q4", "base-backend"), - gallery.Variant{Model: "qwen3-8b-q8", MinMemory: "0GiB"}) + gallery.Variant{Model: "qwen3-8b-q8"}) base.URLs = []string{"https://example.invalid/qwen3"} newGallery(base, entry("qwen3-8b-q8", "upgrade-backend")) diff --git a/core/gallery/variants_lint_test.go b/core/gallery/variants_lint_test.go index a60de7783..8e7aef94d 100644 --- a/core/gallery/variants_lint_test.go +++ b/core/gallery/variants_lint_test.go @@ -75,28 +75,6 @@ func checkVariantReferences(entries []gallery.GalleryModel) []variantViolation { return violations } -// checkVariantMemory verifies every authored memory figure parses. -// -// Absence is fine and deliberately not flagged: a variant that declares nothing -// gets its size from a live probe, and even a probe that fails leaves a -// legitimate unknown that selection handles by ranking the variant last. An -// UNPARSEABLE figure is different, because it makes selection fail outright for -// the whole entry rather than degrade. -func checkVariantMemory(entries []gallery.GalleryModel) []variantViolation { - var violations []variantViolation - for _, e := range entries { - if !e.HasVariants() { - continue - } - for _, v := range e.Variants { - if _, _, err := v.AuthoredMinMemory(); err != nil { - violations = append(violations, variantViolation{Entry: e.Name, Variant: v.Model, Detail: "has a bad min_memory: " + err.Error()}) - } - } - } - 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. @@ -145,11 +123,13 @@ var _ = Describe("gallery variant lint helpers", func() { }) It("passes every invariant on a valid entry", func() { + // Authoring is nothing but a list of names, so a bare list of them must + // be entirely valid. entries := variantFixture( entryWithVariants("base", "u://base", - gallery.Variant{Model: "big", MinMemory: "24GiB"}, - gallery.Variant{Model: "mid", MinMemory: "12GiB"}, - gallery.Variant{Model: "metal-big", MinMemory: "32GiB"}, + gallery.Variant{Model: "big"}, + gallery.Variant{Model: "mid"}, + gallery.Variant{Model: "metal-big"}, gallery.Variant{Model: "small"}, ), plainEntry("big", "u://big"), @@ -159,19 +139,6 @@ var _ = Describe("gallery variant lint helpers", func() { ) Expect(checkVariantReferences(entries)).To(BeEmpty()) - Expect(checkVariantMemory(entries)).To(BeEmpty()) - }) - - It("passes every invariant on an entry that declares no memory at all", func() { - // Authoring is meant to be nothing but a list of names, so an entry - // whose variants carry no figures must be entirely valid. - entries := variantFixture( - entryWithVariants("base", "u://base", gallery.Variant{Model: "big"}), - plainEntry("big", "u://big"), - ) - - Expect(checkVariantReferences(entries)).To(BeEmpty()) - Expect(checkVariantMemory(entries)).To(BeEmpty()) }) Describe("checkVariantReferences", func() { @@ -212,31 +179,6 @@ var _ = Describe("gallery variant lint helpers", func() { }) }) - Describe("checkVariantMemory", func() { - It("flags a variant whose memory figure cannot be parsed", func() { - entries := variantFixture( - entryWithVariants("base", "u://base", gallery.Variant{Model: "a", MinMemory: "lots"}), - plainEntry("a", "u://a"), - ) - - violations := checkVariantMemory(entries) - Expect(violations).To(HaveLen(1)) - Expect(violations[0].Variant).To(Equal("a")) - Expect(violations[0].Detail).To(ContainSubstring("bad min_memory")) - }) - - It("accepts a variant that declares no figure at all", func() { - // The overwhelmingly common shape: a bare name, whose size the - // installer probes live. Flagging it would make the rule reject the - // authoring style the design exists to enable. - entries := variantFixture( - entryWithVariants("base", "u://base", gallery.Variant{Model: "a"}), - plainEntry("a", "u://a"), - ) - - Expect(checkVariantMemory(entries)).To(BeEmpty()) - }) - }) }) var _ = Describe("gallery/index.yaml variant invariants", Ordered, func() { @@ -255,9 +197,4 @@ var _ = Describe("gallery/index.yaml variant invariants", Ordered, func() { v := checkVariantReferences(entries) Expect(v).To(BeEmpty(), formatViolations(v)) }) - - It("declares only parseable memory figures", func() { - v := checkVariantMemory(entries) - Expect(v).To(BeEmpty(), formatViolations(v)) - }) }) diff --git a/core/schema/gallery-model.schema.json b/core/schema/gallery-model.schema.json index 0cf0184d1..0471f6dce 100644 --- a/core/schema/gallery-model.schema.json +++ b/core/schema/gallery-model.schema.json @@ -67,10 +67,6 @@ "model": { "type": "string", "description": "Name of a gallery entry that declares no variants of its own" - }, - "min_memory": { - "type": "string", - "description": "Authored memory requirement, for example 20GiB. Optional, and only needed to override the size the installer probes live from the model's weights when that figure is wrong. Authoritative: an authored figure suppresses the probe entirely." } }, "additionalProperties": false diff --git a/core/services/galleryop/model_variant_test.go b/core/services/galleryop/model_variant_test.go index ada429130..689a2ecc4 100644 --- a/core/services/galleryop/model_variant_test.go +++ b/core/services/galleryop/model_variant_test.go @@ -62,10 +62,12 @@ var _ = Describe("LocalModelManager variant selection", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { Expect(os.RemoveAll(modelsDir)).To(Succeed()) }) - // "0GiB" always fits, so auto-selection would take the upgrade on any - // machine. Only an honored pin can land the install on the base. + // The upgrade declares no size and carries no weight files, so the probe + // reports an unknown, the variant survives the filter and auto-selection + // would take it on any machine. Only an honored pin can land the install + // on the base. entries := []gallery.GalleryModel{ - entry("qwen3-8b-q4", "base-backend", gallery.Variant{Model: "qwen3-8b-q8", MinMemory: "0GiB"}), + entry("qwen3-8b-q4", "base-backend", gallery.Variant{Model: "qwen3-8b-q8"}), entry("qwen3-8b-q8", "upgrade-backend"), } out, err := yaml.Marshal(entries)