mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-02 11:30:44 -04:00
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:
84
core/gallery/resolve_candidate.go
Normal file
84
core/gallery/resolve_candidate.go
Normal 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])
|
||||
}
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user