feat(gallery): add hardware-aware model variant resolver

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-18 10:04:24 +00:00
parent 6caf0b40f9
commit 48821a00c7
2 changed files with 152 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
package gallery
import (
"errors"
"fmt"
"strings"
)
var (
// ErrNoCandidateMatch is returned when no candidate satisfies the host.
ErrNoCandidateMatch = errors.New("no model variant matches this system")
// ErrPinNotFound is returned when a pinned variant is absent from the list.
ErrPinNotFound = errors.New("pinned model variant not found")
)
// ResolveEnv describes the host properties a candidate is matched against.
//
// It is a plain struct rather than a *SystemState so the resolver stays pure
// and testable across hardware shapes without touching the machine.
type ResolveEnv struct {
// Capability is the raw value from SystemState.DetectedCapability().
Capability string
// VRAM is total available VRAM in bytes, already capped by the operator
// VRAM budget because the cap is applied inside detection.
VRAM uint64
}
// ResolveCandidate returns the first candidate the host satisfies, or the
// pinned candidate when pin is non-empty.
//
// Why first-match rather than best-match: the list order is the policy. It is
// authored deliberately and lint-checked, which keeps selection explainable
// (you can read the list top to bottom) and keeps fallback expressible without
// a scoring function nobody can predict.
func ResolveCandidate(candidates []Candidate, env ResolveEnv, pin string) (Candidate, error) {
if pin != "" {
for _, c := range candidates {
if strings.EqualFold(c.Model, pin) {
return c, nil
}
}
return Candidate{}, fmt.Errorf("%w: %q is not among the %d variants of this model", ErrPinNotFound, pin, len(candidates))
}
reasons := make([]string, 0, len(candidates))
for _, c := range candidates {
if c.Capability != "" && c.Capability != env.Capability {
reasons = append(reasons, fmt.Sprintf("%s requires capability %q", c.Model, c.Capability))
continue
}
floor, declared, err := c.EffectiveMinVRAM()
if err != nil {
return Candidate{}, err
}
// A candidate with no declared floor states no requirement and
// passes. Lint guarantees only the final last-resort candidate is
// in that position for the official gallery.
if declared && env.VRAM < floor {
reasons = append(reasons, fmt.Sprintf("%s needs %s VRAM", c.Model, humanBytes(floor)))
continue
}
return c, nil
}
return Candidate{}, fmt.Errorf(
"%w: capability %q with %s VRAM available; candidates: %s",
ErrNoCandidateMatch, env.Capability, humanBytes(env.VRAM), strings.Join(reasons, "; "),
)
}
func humanBytes(b uint64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%dB", b)
}
div, exp := uint64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f%ciB", float64(b)/float64(div), "KMGTPE"[exp])
}

View File

@@ -38,3 +38,71 @@ var _ = Describe("Candidate.EffectiveMinVRAM", func() {
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("ResolveCandidate", func() {
gib := func(n uint64) uint64 { return n * 1024 * 1024 * 1024 }
list := []gallery.Candidate{
{Model: "m-mlx", Capability: "metal", MinVRAM: "16GiB"},
{Model: "m-vllm", Capability: "nvidia", MinVRAM: "20GiB"},
{Model: "m-q8", MinVRAM: "12GiB"},
{Model: "m-q4", MinVRAM: "6GiB"},
{Model: "m-q4-cpu"},
}
It("picks the capability-matched candidate when VRAM allows", func() {
got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "")
Expect(err).ToNot(HaveOccurred())
Expect(got.Model).To(Equal("m-vllm"))
})
It("skips a capability match whose VRAM floor does not fit", func() {
got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(8)}, "")
Expect(err).ToNot(HaveOccurred())
Expect(got.Model).To(Equal("m-q4"))
})
It("ignores candidates whose capability does not match the host", func() {
got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "metal", VRAM: gib(24)}, "")
Expect(err).ToNot(HaveOccurred())
Expect(got.Model).To(Equal("m-mlx"))
})
It("falls through to the unconstrained last resort on a tiny host", func() {
got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "default", VRAM: gib(2)}, "")
Expect(err).ToNot(HaveOccurred())
Expect(got.Model).To(Equal("m-q4-cpu"))
})
It("honors a pin even when the host would have chosen otherwise", func() {
got, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "m-q8")
Expect(err).ToNot(HaveOccurred())
Expect(got.Model).To(Equal("m-q8"))
})
It("fails loudly when the pin names an absent candidate", func() {
_, err := gallery.ResolveCandidate(list, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "m-gone")
Expect(err).To(MatchError(gallery.ErrPinNotFound))
Expect(err.Error()).To(ContainSubstring("m-gone"))
})
It("reports the host state when nothing matches", func() {
constrained := []gallery.Candidate{{Model: "big", MinVRAM: "80GiB"}}
_, err := gallery.ResolveCandidate(constrained, gallery.ResolveEnv{Capability: "default", VRAM: gib(8)}, "")
Expect(err).To(MatchError(gallery.ErrNoCandidateMatch))
Expect(err.Error()).To(ContainSubstring("default"))
Expect(err.Error()).To(ContainSubstring("big"))
})
It("propagates an unparseable floor instead of treating it as unconstrained", func() {
bad := []gallery.Candidate{{Model: "bad", MinVRAM: "lots"}}
_, err := gallery.ResolveCandidate(bad, gallery.ResolveEnv{Capability: "default", VRAM: gib(8)}, "")
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("bad"))
})
It("rejects an empty candidate list", func() {
_, err := gallery.ResolveCandidate(nil, gallery.ResolveEnv{Capability: "default", VRAM: gib(8)}, "")
Expect(err).To(MatchError(gallery.ErrNoCandidateMatch))
})
})