diff --git a/core/gallery/meta_install_test.go b/core/gallery/meta_install_test.go index 158f2aaf5..cdada35c0 100644 --- a/core/gallery/meta_install_test.go +++ b/core/gallery/meta_install_test.go @@ -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)) + }) +}) diff --git a/core/gallery/model_artifacts.go b/core/gallery/model_artifacts.go index 07924d9df..01de02d03 100644 --- a/core/gallery/model_artifacts.go +++ b/core/gallery/model_artifacts.go @@ -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 { diff --git a/core/gallery/models.go b/core/gallery/models.go index 4d39cc0cc..88c81e48a 100644 --- a/core/gallery/models.go +++ b/core/gallery/models.go @@ -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) {