Skip to content

Commit 9a43a27

Browse files
committed
fix(gallery): rank the entry's own build against its variants
Variant selection pulled the declaring entry's own payload, the base, out of the candidate set and consulted it only once every declared variant had been rejected. Two real failures followed. A variant whose size the probe cannot determine deliberately survives the memory filter, because nothing proves it does not fit. As the only survivor it then won outright on any host, however small: a 2GiB machine installed an unmeasured variant in preference to the 4GiB build the entry itself ships, with no warning. 241 of the 1280 current index entries carry no files and no size, which is exactly that shape. "Largest wins" also broke whenever the base was the largest. An author writing a Q8 entry that offers a Q4 downgrade for small hosts, a natural shape that nothing in the lint, schema or docs discourages, had the Q4 installed on every large host instead. Make the base an ordinary participant. It is still exempt from both filters, so selection always terminates on something installable, but it is now ranked against the variants: a proven fit first and largest, then the base, then any variant whose size nothing could measure. Both failures disappear together. The base is probed for its size accordingly, which it was not before, because an unsized base would lose every contest to an unmeasurable variant. FellBackToBase is kept but narrowed to "no declared variant survived", rather than "the base was chosen", since the base now also wins on merit and that is not worth warning about. A recalled variant pin also became a permanent install failure. A pin the caller supplies on this request must stay fatal, but one recalled from ._gallery_<name>.yaml can be invalidated by any later gallery edit, and failing on it turned one rename into a model that could never be reinstalled or upgraded again short of deleting a dotfile the user has never heard of. A stale recalled pin is now dropped with a warning naming it, and selection runs as if it had never been recorded. Also drop the last textual reference to two abandoned designs from the DetectedCapability comment, correct the documented variants JSON example, which showed a memory_bytes of 0 that omitempty makes impossible, and remove an em dash from the install skill. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 84de7a6 commit 9a43a27

10 files changed

Lines changed: 322 additions & 87 deletions

File tree

.agents/adding-gallery-models.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,15 @@ Rules:
114114
and must not declare `variants` of its own.
115115
- **Order carries no meaning.** Do not try to encode a preference; write the
116116
list in whatever order reads best.
117+
- **A variant may be smaller than the declaring entry.** Offering a downgrade
118+
for small hosts is a normal shape: the declaring entry's own build competes on
119+
size like every other candidate, so a large host keeps the large build.
117120
- **Do not describe hardware.** At install time LocalAI drops variants whose
118121
backend cannot run on the host, drops those that do not fit available memory,
119-
and installs the largest survivor, falling back to the declaring entry's own
120-
build. Sizes are measured live from the weights and cached, so nothing has to
121-
be written down.
122+
and installs the largest of what is left, the declaring entry's own build
123+
included. That build is never dropped, so selection always terminates on
124+
something installable. Sizes are measured live from the weights and cached, so
125+
nothing has to be written down.
122126
- A variant is nothing but a name; there is no per-variant memory field. When
123127
the measured size for a build is wrong, correct it on the referenced entry by
124128
setting that entry's own `size:` (e.g. `size: "20GiB"`). The estimator prefers

core/gallery/describe_variants.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ type VariantView struct {
1212
// it, and it is also the reason Fits may be false on a host whose memory
1313
// would be ample.
1414
Backend string `json:"backend"`
15-
// MemoryBytes is the measured footprint, or 0 when it could not be
16-
// determined. Zero is unknown, never "needs nothing", so a client must
17-
// render it as unknown rather than as a free option.
15+
// MemoryBytes is the measured footprint. It is omitted from the JSON
16+
// entirely when the size could not be determined, rather than serialized as
17+
// a zero that a client would have to know to read as unknown. An absent key
18+
// never means "needs nothing".
1819
MemoryBytes uint64 `json:"memory_bytes,omitempty"`
1920
// Fits reports whether auto-selection would consider this variant on this
2021
// host: its backend can run here and its known footprint is within budget.

core/gallery/models.go

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,18 @@ func variantOptions(models []*GalleryModel, entry *GalleryModel, env ResolveEnv)
111111
options = append(options, option)
112112
}
113113

114-
// The base is never filtered nor ranked, so its size would change nothing
115-
// and probing it would spend a round trip on a discarded answer.
116-
return append(options, VariantOption{
114+
// The base is probed like any other candidate. It is never filtered out, but
115+
// it IS ranked against the variants, and an unsized base would lose every
116+
// contest to a variant whose size nothing could measure.
117+
base := VariantOption{
117118
Variant: Variant{Model: entry.Name},
118119
Backend: entry.Backend,
119120
IsBase: true,
120-
}), nil
121+
}
122+
if env.ProbeMemory != nil {
123+
base.ProbedMemory = env.ProbeMemory(entry)
124+
}
125+
return append(options, base), nil
121126
}
122127

123128
// probeContextLength is the context size the footprint estimate is taken at.
@@ -180,9 +185,10 @@ func probeEntryMemory(ctx context.Context, entry *GalleryModel) uint64 {
180185
//
181186
// The base entry always resolves, whatever the host has. It is a complete entry
182187
// that every older LocalAI release installs unconditionally, so refusing it here
183-
// would make the gallery behave worse the newer the client is. That is also why
184-
// the base declares no memory requirement of its own: a floor that can only
185-
// warn cannot change any outcome.
188+
// would make the gallery behave worse the newer the client is. Being exempt from
189+
// the filters does not make it exempt from ranking: it is measured and compared
190+
// like every declared variant, and wins by default only once they have all been
191+
// ruled out.
186192
func ResolveVariant(models []*GalleryModel, entry *GalleryModel, env ResolveEnv, pin string) (*GalleryModel, Variant, error) {
187193
options, err := variantOptions(models, entry, env)
188194
if err != nil {
@@ -462,19 +468,40 @@ func InstallModelFromGallery(
462468
// back under the entry's own name would miss the record for every custom-named
463469
// install and silently re-resolve a deliberately pinned model onto a
464470
// different variant, possibly swapping its backend.
471+
//
472+
// recalledPin tracks where the pin came from, because the two sources must
473+
// fail differently when the name no longer resolves. See below.
474+
recalledPin := ""
465475
if pin == "" {
466476
installName := model.Name
467477
if req.Name != "" {
468478
installName = req.Name
469479
}
470480
if previous, err := GetLocalModelConfiguration(systemState.Model.ModelsPath, installName); err == nil && previous != nil {
471-
pin = previous.PinnedVariant
481+
recalledPin = previous.PinnedVariant
482+
pin = recalledPin
472483
}
473484
}
474485

475486
env := HostResolveEnv(ctx, systemState)
476487

477488
resolved, variant, err := ResolveVariant(models, model, env, pin)
489+
// A pin the caller supplied on this request must stay fatal: they named
490+
// something this entry cannot give, and installing anything else would
491+
// report success for a request that was not honored.
492+
//
493+
// A pin recalled from disk is different. The gallery can rename or withdraw
494+
// a variant long after it was pinned, and the user is not asking for it
495+
// again on this call. Failing here would turn one gallery edit into a
496+
// permanently unrepairable model whose only remedy is deleting a dotfile
497+
// they have never heard of, so the stale pin is dropped and selection runs
498+
// as if it had never been recorded.
499+
if err != nil && recalledPin != "" && errors.Is(err, ErrPinNotFound) {
500+
xlog.Warn("The recorded variant pin for this model no longer exists in the gallery; re-selecting automatically",
501+
"model", model.Name, "dropped_pin", recalledPin)
502+
pin = ""
503+
resolved, variant, err = ResolveVariant(models, model, env, "")
504+
}
478505
if err != nil {
479506
return err
480507
}

core/gallery/resolve_variant.go

Lines changed: 86 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ type VariantOption struct {
2727
// precisely why a gallery author never has to describe hardware.
2828
Backend string
2929
// IsBase marks the declaring entry's own payload. It is exempt from every
30-
// filter and is the answer when nothing else survives, because the entry
31-
// must stay installable on every host and for every client.
30+
// filter, because the entry must stay installable on every host and for
31+
// every client, but it is otherwise an ordinary candidate: it is ranked
32+
// against the declared variants rather than consulted only once they have
33+
// all been rejected.
3234
IsBase bool
3335
// ProbedMemory is the footprint measured live from the referenced entry's
3436
// weights, in bytes. It is the only source of a variant's size. An author
@@ -87,9 +89,14 @@ func (e ResolveEnv) backendRuns(backend string) bool {
8789
// VariantSelection is the outcome of a selection pass.
8890
type VariantSelection struct {
8991
Option VariantOption
90-
// FellBackToBase reports that no declared variant survived and the entry's
91-
// own payload was chosen instead. Callers log this, because a host quietly
92-
// taking the base when upgrades were on offer is worth being able to see.
92+
// FellBackToBase reports that no declared variant survived the filters and
93+
// the entry's own payload was all that remained. Callers log this, because a
94+
// host quietly taking the base when upgrades were on offer is worth being
95+
// able to see.
96+
//
97+
// It is deliberately narrower than "the base was selected": the base also
98+
// wins on merit whenever it outranks every surviving variant, and that is an
99+
// ordinary, uninteresting outcome rather than something to warn about.
93100
FellBackToBase bool
94101
// Reasons explains, one line per rejected variant, why it was dropped.
95102
Reasons []string
@@ -107,11 +114,13 @@ type VariantSelection struct {
107114
// dropped. A variant with an UNKNOWN requirement survives, because nothing
108115
// proves it does not fit and refusing on a size the probe could not read
109116
// would let a network hiccup silently downgrade what gets installed.
110-
// 4. The largest survivor wins. A bigger footprint is a higher quality
111-
// quantization of the same model, so among things that fit, more is better.
112-
// Unknown requirements rank last, so a proven fit always beats a guess.
113-
// 5. With no survivor the base option wins. The base always installs; this
114-
// never refuses.
117+
// 4. The base survives both filters unconditionally, and then competes. It is
118+
// a candidate like any other, not a last resort: an entry whose own build is
119+
// the largest thing that fits must win against a smaller variant, and a base
120+
// of known size must win against a variant whose size nothing could measure.
121+
// 5. The survivors are ranked and the best one wins, by rankOf below.
122+
// 6. With no survivor at all, which can only happen when the caller supplied no
123+
// base, there is nothing to install and this reports ErrNoVariantMatch.
115124
func SelectVariant(options []VariantOption, env ResolveEnv, pin string) (VariantSelection, error) {
116125
if pin != "" {
117126
for _, o := range options {
@@ -125,54 +134,88 @@ func SelectVariant(options []VariantOption, env ResolveEnv, pin string) (Variant
125134
type ranked struct {
126135
option VariantOption
127136
memory uint64
128-
known bool
137+
rank int
129138
}
130139

131-
var base *VariantOption
132140
survivors := make([]ranked, 0, len(options))
133141
reasons := make([]string, 0, len(options))
142+
survivingVariants := 0
134143

135144
for i := range options {
136145
o := options[i]
137-
if o.IsBase {
138-
base = &options[i]
139-
continue
140-
}
141-
142-
if !env.backendRuns(o.Backend) {
143-
reasons = append(reasons, fmt.Sprintf("%s needs backend %q, which cannot run on this system", o.Variant.Model, o.Backend))
144-
continue
145-
}
146-
147146
memory, known := o.EffectiveMemory()
148-
if known && memory > env.AvailableMemory {
149-
reasons = append(reasons, fmt.Sprintf("%s needs %s of memory", o.Variant.Model, humanBytes(memory)))
150-
continue
147+
148+
// The base skips both gates. There is nothing below it, so refusing it
149+
// would make an entry every older LocalAI installs fine uninstallable on
150+
// newer ones.
151+
if !o.IsBase {
152+
if !env.backendRuns(o.Backend) {
153+
reasons = append(reasons, fmt.Sprintf("%s needs backend %q, which cannot run on this system", o.Variant.Model, o.Backend))
154+
continue
155+
}
156+
if known && memory > env.AvailableMemory {
157+
reasons = append(reasons, fmt.Sprintf("%s needs %s of memory", o.Variant.Model, humanBytes(memory)))
158+
continue
159+
}
160+
survivingVariants++
151161
}
152162

153-
survivors = append(survivors, ranked{option: o, memory: memory, known: known})
163+
survivors = append(survivors, ranked{option: o, memory: memory, rank: rankOf(o, env)})
154164
}
155165

156-
if len(survivors) > 0 {
157-
// Stable so that variants with identical requirements keep their
158-
// authored order, which is the only thing order still decides.
159-
sort.SliceStable(survivors, func(i, j int) bool {
160-
if survivors[i].known != survivors[j].known {
161-
return survivors[i].known
162-
}
163-
return survivors[i].memory > survivors[j].memory
164-
})
165-
return VariantSelection{Option: survivors[0].option, Reasons: reasons}, nil
166+
if len(survivors) == 0 {
167+
return VariantSelection{}, fmt.Errorf(
168+
"%w: %s of memory available; variants: %s",
169+
ErrNoVariantMatch, humanBytes(env.AvailableMemory), strings.Join(reasons, "; "),
170+
)
166171
}
167172

168-
if base != nil {
169-
return VariantSelection{Option: *base, FellBackToBase: true, Reasons: reasons}, nil
170-
}
173+
// Stable so that options within one rank and of identical size keep their
174+
// authored order, which is the only thing order still decides.
175+
sort.SliceStable(survivors, func(i, j int) bool {
176+
if survivors[i].rank != survivors[j].rank {
177+
return survivors[i].rank < survivors[j].rank
178+
}
179+
return survivors[i].memory > survivors[j].memory
180+
})
181+
182+
winner := survivors[0].option
183+
return VariantSelection{
184+
Option: winner,
185+
// Only a base that won by default is worth reporting. A base that
186+
// outranked live competition is an ordinary selection.
187+
FellBackToBase: winner.IsBase && survivingVariants == 0,
188+
Reasons: reasons,
189+
}, nil
190+
}
171191

172-
return VariantSelection{}, fmt.Errorf(
173-
"%w: %s of memory available; variants: %s",
174-
ErrNoVariantMatch, humanBytes(env.AvailableMemory), strings.Join(reasons, "; "),
175-
)
192+
// Ranks, best first. Within a rank the larger footprint wins, because a bigger
193+
// build is a higher quality quantization of the same model.
194+
const (
195+
// rankProvenFit is a measured size that the host is measured to satisfy.
196+
rankProvenFit = iota
197+
// rankBase is the entry's own build when it is not a proven fit: either it
198+
// needs more memory than the host reports, or its size could not be
199+
// measured either. It still outranks any unsized variant, because it is the
200+
// payload the entry is guaranteed to be able to install and a variant of
201+
// unmeasurable size is a guess. Taking the guess on a host that cannot be
202+
// shown to accommodate it is how an unreachable network silently changes
203+
// what gets installed.
204+
rankBase
205+
// rankUnknownFit is a variant whose size nothing could measure. Nothing
206+
// proves it does not fit, so it is not dropped, but nothing proves it does
207+
// either, so it ranks last.
208+
rankUnknownFit
209+
)
210+
211+
func rankOf(o VariantOption, env ResolveEnv) int {
212+
if memory, known := o.EffectiveMemory(); known && memory <= env.AvailableMemory {
213+
return rankProvenFit
214+
}
215+
if o.IsBase {
216+
return rankBase
217+
}
218+
return rankUnknownFit
176219
}
177220

178221
func humanBytes(b uint64) string {

core/gallery/resolve_variant_test.go

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ var _ = Describe("SelectVariant", func() {
4747
}
4848

4949
// The base is exempt from every filter, which the fallback specs below pin
50-
// down; its size is carried only so ranking has nothing special to do.
50+
// down, but it is ranked against the variants like any other candidate, so
51+
// its size is load-bearing.
5152
base := func(model string, probed uint64) gallery.VariantOption {
5253
o := option(model, "llama-cpp", probed)
5354
o.IsBase = true
@@ -159,8 +160,62 @@ var _ = Describe("SelectVariant", func() {
159160

160161
selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(4)}, "")
161162
Expect(err).ToNot(HaveOccurred())
162-
Expect(selection.Option.Variant.Model).To(Equal("m-unknown"))
163+
// Surviving is observable through the rejection reasons: a dropped
164+
// variant is always accounted for there, and this one is not.
165+
Expect(selection.Reasons).ToNot(ContainElement(ContainSubstring("m-unknown")))
166+
// It survives, but it does not win: the base is a sized, guaranteed
167+
// payload and an unmeasurable variant is a guess.
168+
Expect(selection.Option.Variant.Model).To(Equal("m-base"))
169+
Expect(selection.FellBackToBase).To(BeFalse())
170+
})
171+
172+
It("installs the base rather than an unsized variant on a host too small for either", func() {
173+
// The exact shape 241 of the current index entries have: a referenced
174+
// entry with no files and no size, whose probe can only answer
175+
// "unknown". Ranking it above the base would install an unmeasured
176+
// download on a machine with 2GiB, and would do so silently.
177+
options := []gallery.VariantOption{
178+
option("m-unknown", "llama-cpp", 0),
179+
base("m-base-q4", gib(4)),
180+
}
181+
182+
selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(2)}, "")
183+
Expect(err).ToNot(HaveOccurred())
184+
Expect(selection.Option.Variant.Model).To(Equal("m-base-q4"))
185+
Expect(selection.Option.IsBase).To(BeTrue())
186+
})
187+
188+
It("selects the base when the base is the largest option that fits", func() {
189+
// A Q8 base offering a Q4 downgrade for small hosts is a natural
190+
// authoring shape. Treating the base as a last resort would install
191+
// the Q4 on every host large enough for the Q8 and permanently
192+
// downgrade the user.
193+
options := []gallery.VariantOption{
194+
option("m-q4", "llama-cpp", gib(4)),
195+
base("m-base-q8", gib(8)),
196+
}
197+
198+
selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(16)}, "")
199+
Expect(err).ToNot(HaveOccurred())
200+
Expect(selection.Option.Variant.Model).To(Equal("m-base-q8"))
201+
// The Q4 survived every filter, so this is the base winning on rank
202+
// and not the base being fallen back to.
163203
Expect(selection.FellBackToBase).To(BeFalse())
204+
Expect(selection.Reasons).To(BeEmpty())
205+
})
206+
207+
It("selects a smaller variant when the base does not fit but the variant does", func() {
208+
// The mirror of the spec above: the base competes, it does not win by
209+
// default, so a host that cannot hold it must still take the downgrade
210+
// the entry offers for exactly that case.
211+
options := []gallery.VariantOption{
212+
option("m-q4", "llama-cpp", gib(4)),
213+
base("m-base-q8", gib(8)),
214+
}
215+
216+
selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(6)}, "")
217+
Expect(err).ToNot(HaveOccurred())
218+
Expect(selection.Option.Variant.Model).To(Equal("m-q4"))
164219
})
165220

166221
It("reports why a probed size that does not fit was rejected", func() {
@@ -213,6 +268,21 @@ var _ = Describe("SelectVariant", func() {
213268
selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: 0}, "")
214269
Expect(err).ToNot(HaveOccurred())
215270
Expect(selection.Option.Variant.Model).To(Equal("m-base"))
271+
Expect(selection.Option.IsBase).To(BeTrue())
272+
Expect(selection.FellBackToBase).To(BeTrue())
273+
})
274+
275+
It("prefers the base to an unsized variant even when the base itself is unsized", func() {
276+
// Neither can be shown to fit, so nothing separates them on size. The
277+
// base is still the payload the entry is guaranteed to install.
278+
options := []gallery.VariantOption{
279+
option("m-unknown", "llama-cpp", 0),
280+
base("m-base", 0),
281+
}
282+
283+
selection, err := gallery.SelectVariant(options, gallery.ResolveEnv{AvailableMemory: gib(8)}, "")
284+
Expect(err).ToNot(HaveOccurred())
285+
Expect(selection.Option.Variant.Model).To(Equal("m-base"))
216286
})
217287

218288
It("explains why each variant was rejected", func() {

0 commit comments

Comments
 (0)