mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-02 11:30:44 -04:00
feat(gallery): resolve meta model entries to hardware-appropriate variants at install
Meta gallery entries carry an ordered candidate list; at install time the first candidate the host satisfies is resolved and its payload installed under the meta's name, so the model keeps a stable name regardless of which variant backs it. The resolution is recorded in the installed gallery config so a reinstall honors a prior pin and operators can see the backing variant. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -41,3 +41,77 @@ candidates:
|
||||
Expect(m.Candidates[1].Capability).To(BeEmpty())
|
||||
})
|
||||
})
|
||||
|
||||
var _ = Describe("ResolveMetaModel", func() {
|
||||
gib := func(n uint64) uint64 { return n * 1024 * 1024 * 1024 }
|
||||
|
||||
newModel := func(name, url, description, icon string) *gallery.GalleryModel {
|
||||
m := &gallery.GalleryModel{}
|
||||
m.Name = name
|
||||
m.URL = url
|
||||
m.Description = description
|
||||
m.Icon = icon
|
||||
return m
|
||||
}
|
||||
|
||||
var models []*gallery.GalleryModel
|
||||
var meta *gallery.GalleryModel
|
||||
|
||||
BeforeEach(func() {
|
||||
concreteVLLM := newModel("qwen3-8b-vllm-awq", "file://vllm.yaml", "AWQ variant", "vllm.png")
|
||||
concreteGGUF := newModel("qwen3-8b-gguf-q4", "file://gguf.yaml", "GGUF variant", "gguf.png")
|
||||
meta = newModel("qwen3-8b", "file://gguf.yaml", "Qwen3 8B", "qwen.png")
|
||||
meta.Tags = []string{"llm"}
|
||||
meta.Candidates = []gallery.Candidate{
|
||||
{Model: "qwen3-8b-vllm-awq", Capability: "nvidia", MinVRAM: "20GiB"},
|
||||
{Model: "qwen3-8b-gguf-q4"},
|
||||
}
|
||||
models = []*gallery.GalleryModel{concreteVLLM, concreteGGUF, meta}
|
||||
})
|
||||
|
||||
It("installs the concrete payload under the meta's name", func() {
|
||||
resolved, candidate, err := gallery.ResolveMetaModel(models, meta, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(candidate.Model).To(Equal("qwen3-8b-vllm-awq"))
|
||||
Expect(resolved.Name).To(Equal("qwen3-8b"))
|
||||
Expect(resolved.URL).To(Equal("file://vllm.yaml"))
|
||||
})
|
||||
|
||||
It("presents the meta's metadata, not the variant's", func() {
|
||||
resolved, _, err := gallery.ResolveMetaModel(models, meta, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(resolved.Description).To(Equal("Qwen3 8B"))
|
||||
Expect(resolved.Icon).To(Equal("qwen.png"))
|
||||
Expect(resolved.Tags).To(ConsistOf("llm"))
|
||||
})
|
||||
|
||||
It("keeps the name stable when a variant is pinned", func() {
|
||||
resolved, candidate, err := gallery.ResolveMetaModel(models, meta, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "qwen3-8b-gguf-q4")
|
||||
Expect(err).ToNot(HaveOccurred())
|
||||
Expect(candidate.Model).To(Equal("qwen3-8b-gguf-q4"))
|
||||
Expect(resolved.Name).To(Equal("qwen3-8b"))
|
||||
Expect(resolved.URL).To(Equal("file://gguf.yaml"))
|
||||
})
|
||||
|
||||
It("errors when a candidate references a missing entry", func() {
|
||||
meta.Candidates = []gallery.Candidate{{Model: "does-not-exist"}}
|
||||
_, _, err := gallery.ResolveMetaModel(models, meta, gallery.ResolveEnv{Capability: "default", VRAM: gib(8)}, "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("does-not-exist"))
|
||||
})
|
||||
|
||||
It("refuses a candidate that is itself a meta entry", func() {
|
||||
nested := newModel("nested", "", "", "")
|
||||
nested.Candidates = []gallery.Candidate{{Model: "qwen3-8b-gguf-q4"}}
|
||||
models = append(models, nested)
|
||||
meta.Candidates = []gallery.Candidate{{Model: "nested"}}
|
||||
_, _, err := gallery.ResolveMetaModel(models, meta, gallery.ResolveEnv{Capability: "default", VRAM: gib(8)}, "")
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("nested"))
|
||||
})
|
||||
|
||||
It("surfaces a bad pin", func() {
|
||||
_, _, err := gallery.ResolveMetaModel(models, meta, gallery.ResolveEnv{Capability: "nvidia", VRAM: gib(24)}, "nope")
|
||||
Expect(err).To(MatchError(gallery.ErrPinNotFound))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ type ArtifactMaterializer interface {
|
||||
|
||||
type installOptions struct {
|
||||
materializer ArtifactMaterializer
|
||||
variant string
|
||||
}
|
||||
|
||||
type InstallOption func(*installOptions)
|
||||
@@ -31,6 +32,14 @@ func WithArtifactMaterializer(materializer ArtifactMaterializer) InstallOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithVariant pins a meta model entry to a specific candidate by name,
|
||||
// bypassing hardware-based resolution. Ignored for non-meta entries.
|
||||
func WithVariant(variant string) InstallOption {
|
||||
return func(options *installOptions) {
|
||||
options.variant = variant
|
||||
}
|
||||
}
|
||||
|
||||
func applyInstallOptions(options ...InstallOption) installOptions {
|
||||
result := installOptions{materializer: modelartifacts.NewDefaultManager()}
|
||||
for _, option := range options {
|
||||
|
||||
@@ -60,6 +60,13 @@ type ModelConfig struct {
|
||||
ConfigFile string `yaml:"config_file"`
|
||||
Files []File `yaml:"files"`
|
||||
PromptTemplates []PromptTemplate `yaml:"prompt_templates"`
|
||||
|
||||
// The fields below record how a meta entry was resolved, so a reinstall
|
||||
// or upgrade can honor the same pin and so operators can see which
|
||||
// variant a stable model name is actually backed by.
|
||||
MetaName string `yaml:"meta_name,omitempty"`
|
||||
ResolvedVariant string `yaml:"resolved_variant,omitempty"`
|
||||
PinnedVariant string `yaml:"pinned_variant,omitempty"`
|
||||
}
|
||||
|
||||
type File struct {
|
||||
@@ -73,6 +80,50 @@ type PromptTemplate struct {
|
||||
Content string `yaml:"content"`
|
||||
}
|
||||
|
||||
// ResolveMetaModel turns a meta gallery entry into the concrete entry that
|
||||
// should be installed on this host, returning it renamed to the meta's name
|
||||
// and carrying the meta's presentation metadata.
|
||||
//
|
||||
// Why the metadata split: the payload (url, config_file, files, overrides)
|
||||
// must come from the variant because that is what actually gets downloaded,
|
||||
// while the presentation (name, description, icon, tags) must come from the
|
||||
// meta so the installed model presents as the model rather than the variant.
|
||||
func ResolveMetaModel(models []*GalleryModel, meta *GalleryModel, env ResolveEnv, pin string) (*GalleryModel, Candidate, error) {
|
||||
candidate, err := ResolveCandidate(meta.Candidates, env, pin)
|
||||
if err != nil {
|
||||
return nil, Candidate{}, fmt.Errorf("resolving variant for model %q: %w", meta.Name, err)
|
||||
}
|
||||
|
||||
// A pin is an operator override and deliberately bypasses the hardware
|
||||
// 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 floor, declared, verr := candidate.EffectiveMinVRAM(); verr == nil && declared && env.VRAM < floor {
|
||||
xlog.Warn("Pinned model variant declares more VRAM than this system reports; installing anyway because the pin overrides hardware resolution",
|
||||
"model", meta.Name, "variant", candidate.Model, "required_vram", floor, "available_vram", env.VRAM)
|
||||
}
|
||||
}
|
||||
|
||||
concrete := FindGalleryElement(models, candidate.Model)
|
||||
if concrete == nil {
|
||||
return nil, Candidate{}, fmt.Errorf("model %q references variant %q which does not exist in any configured gallery", meta.Name, candidate.Model)
|
||||
}
|
||||
if concrete.IsMeta() {
|
||||
return nil, Candidate{}, fmt.Errorf("model %q references variant %q which is itself a meta entry; meta entries may not nest", meta.Name, candidate.Model)
|
||||
}
|
||||
|
||||
resolved := *concrete
|
||||
resolved.Name = meta.Name
|
||||
resolved.Description = meta.Description
|
||||
resolved.Icon = meta.Icon
|
||||
resolved.License = meta.License
|
||||
resolved.URLs = meta.URLs
|
||||
resolved.Tags = meta.Tags
|
||||
resolved.Candidates = nil
|
||||
|
||||
return &resolved, candidate, nil
|
||||
}
|
||||
|
||||
// Installs a model from the gallery
|
||||
func InstallModelFromGallery(
|
||||
ctx context.Context,
|
||||
@@ -81,7 +132,9 @@ func InstallModelFromGallery(
|
||||
modelLoader *model.ModelLoader,
|
||||
name string, req GalleryModel, downloadStatus func(string, string, string, float64), enforceScan, automaticallyInstallBackend, requireBackendIntegrity bool, options ...InstallOption) error {
|
||||
|
||||
applyModel := func(model *GalleryModel) error {
|
||||
installOpts := applyInstallOptions(options...)
|
||||
|
||||
applyModel := func(model *GalleryModel, record *ModelConfig) error {
|
||||
name = strings.ReplaceAll(name, string(os.PathSeparator), "__")
|
||||
|
||||
var config ModelConfig
|
||||
@@ -113,6 +166,12 @@ func InstallModelFromGallery(
|
||||
return fmt.Errorf("invalid gallery model %+v", model)
|
||||
}
|
||||
|
||||
if record != nil {
|
||||
config.MetaName = record.MetaName
|
||||
config.ResolvedVariant = record.ResolvedVariant
|
||||
config.PinnedVariant = record.PinnedVariant
|
||||
}
|
||||
|
||||
installName := model.Name
|
||||
if req.Name != "" {
|
||||
installName = req.Name
|
||||
@@ -157,7 +216,42 @@ func InstallModelFromGallery(
|
||||
return fmt.Errorf("no model found with name %q", name)
|
||||
}
|
||||
|
||||
return applyModel(model)
|
||||
// Meta-ness is checked before anything looks at the URL: a meta entry also
|
||||
// carries a url as a fallback for older LocalAI releases that do not
|
||||
// understand candidates, so carrying both is normal and meta wins here.
|
||||
if !model.IsMeta() {
|
||||
return applyModel(model, nil)
|
||||
}
|
||||
|
||||
pin := installOpts.variant
|
||||
// A previously recorded pin survives reinstalls and upgrades, so a user
|
||||
// who deliberately chose a variant is not silently re-resolved onto a
|
||||
// different one by a hardware or gallery change.
|
||||
if pin == "" {
|
||||
if previous, err := GetLocalModelConfiguration(systemState.Model.ModelsPath, model.Name); err == nil && previous != nil {
|
||||
pin = previous.PinnedVariant
|
||||
}
|
||||
}
|
||||
|
||||
env := ResolveEnv{
|
||||
Capability: systemState.DetectedCapability(),
|
||||
VRAM: systemState.VRAM,
|
||||
}
|
||||
|
||||
resolved, candidate, err := ResolveMetaModel(models, model, env, pin)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
xlog.Info("Resolved meta model to variant",
|
||||
"model", model.Name, "variant", candidate.Model,
|
||||
"capability", env.Capability, "vram", env.VRAM, "pinned", pin != "")
|
||||
|
||||
return applyModel(resolved, &ModelConfig{
|
||||
MetaName: model.Name,
|
||||
ResolvedVariant: candidate.Model,
|
||||
PinnedVariant: pin,
|
||||
})
|
||||
}
|
||||
|
||||
func InstallModel(ctx context.Context, systemState *system.SystemState, nameOverride string, galleryConfig *ModelConfig, configOverrides map[string]any, downloadStatus func(string, string, string, float64), enforceScan bool, options ...InstallOption) (*lconfig.ModelConfig, error) {
|
||||
|
||||
Reference in New Issue
Block a user