Skip to content

Commit 77d01de

Browse files
committed
feat(gallery): expose model variants for selection over API, CLI and MCP
A gallery entry may carry `variants:`, alternative builds of the same model. Selection already worked at install time, but nothing could see what an entry offered or ask for a specific build, so the feature was undrivable. Listing: `GET /api/models` now reports `variants` and `auto_variant` for the entries that declare variants. Each variant carries its resolved backend, its measured size and whether it fits this host. `auto_variant` is what installing without a choice would pick right now. The new gallery.DescribeVariants runs the same variantOptions + SelectVariant pass the installer runs, so the reported default cannot drift from what installing actually does, and HostResolveEnv is extracted so both derive the host and share pkg/vram's probe cache from one place. Performance: an entry that declares no variants returns early without touching the probe, so the ~1280 ordinary entries cost exactly what they cost before. Selection: `variant` is accepted on POST /models/apply, as a query param on POST /api/models/install/:id, on the gallery apply file/string request, as `local-ai models install --variant`, and as a parameter on the install_model MCP tool (both the httpapi and inproc clients). Empty means auto-select. An unknown variant name now fails the install naming what was requested. This closes a real hole: an entry declaring no variants short-circuits before selection runs, so a requested variant was previously dropped silently and the install reported success. startup.InstallModels ends in a variadic model list, so install options could not be appended to it; InstallModelsWithOptions is added alongside and InstallModels delegates to it. No caller signature changed. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 7159c38 commit 77d01de

20 files changed

Lines changed: 719 additions & 23 deletions

File tree

.agents/adding-gallery-models.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,46 @@ To add a variant (e.g., different quantization), use YAML merge:
9191
uri: huggingface://<gguf-org>/<gguf-repo>/<filename>-Q8_0.gguf
9292
```
9393

94+
## Offering several builds of one model (`variants`)
95+
96+
When the same model is published in more than one quantization, or is also
97+
servable by another engine, add each build as its own ordinary gallery entry and
98+
then point one of them at the others with `variants`:
99+
100+
```yaml
101+
- !!merge <<: *chatml
102+
name: "nanbeige4.1-3b-q4"
103+
# ... the usual urls / overrides / files for the Q4 build ...
104+
variants:
105+
- model: nanbeige4.1-3b-q8
106+
```
107+
108+
Rules:
109+
110+
- The declaring entry is a **complete, normal entry**. It keeps its own
111+
`files`/`overrides` and stays installable on every host and by every older
112+
LocalAI release, which simply ignore `variants`.
113+
- A variant references another gallery entry **by name**. That entry must exist
114+
and must not declare `variants` of its own.
115+
- **Order carries no meaning.** Do not try to encode a preference; write the
116+
list in whatever order reads best.
117+
- **Do not describe hardware.** At install time LocalAI drops variants whose
118+
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+
- `min_memory` on a single variant (e.g. `min_memory: 20GiB`) overrides the
123+
measured size and suppresses the measurement for that variant. Use it only
124+
when you have measured a real load and know the estimate is wrong; most
125+
variants should not carry it.
126+
127+
Users can override the automatic choice with `variant` on `POST /models/apply`,
128+
`local-ai models install --variant`, or the `install_model` MCP tool. See
129+
`docs/content/features/model-gallery.md`.
130+
131+
The gallery lint specs live in `core/gallery`, so run that suite after adding a
132+
`variants` list.
133+
94134
## Available template configs
95135

96136
Look at existing `.yaml` files in `gallery/` to find the right prompt template for your model architecture:

core/cli/models.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type ModelsInstall struct {
3939
RequireBackendIntegrity bool `env:"LOCALAI_REQUIRE_BACKEND_INTEGRITY,REQUIRE_BACKEND_INTEGRITY" help:"If true, reject backend installs without a configured signature verification policy (OCI URIs) or SHA256 (tarball/HTTP URIs)." group:"hardening" default:"false"`
4040
AutoloadBackendGalleries bool `env:"LOCALAI_AUTOLOAD_BACKEND_GALLERIES" help:"If true, automatically loads backend galleries" group:"backends" default:"true"`
4141
ModelArgs []string `arg:"" optional:"" name:"models" help:"Model configuration URLs to load"`
42+
Variant string `name:"variant" help:"Install a specific variant of a gallery entry that declares them, by the variant's model name. Leave unset to let LocalAI auto-select the largest build this machine can run." group:"models"`
4243

4344
ModelsCMDFlags `embed:""`
4445
}
@@ -147,7 +148,11 @@ func (mi *ModelsInstall) Run(ctx *cliContext.Context) error {
147148
}
148149

149150
modelLoader := model.NewModelLoader(systemState)
150-
err = startup.InstallModels(context.Background(), galleryService, galleries, backendGalleries, systemState, modelLoader, !mi.DisablePredownloadScan, mi.AutoloadBackendGalleries, mi.RequireBackendIntegrity, progressCallback, modelName)
151+
var installOptions []gallery.InstallOption
152+
if mi.Variant != "" {
153+
installOptions = append(installOptions, gallery.WithVariant(mi.Variant))
154+
}
155+
err = startup.InstallModelsWithOptions(context.Background(), galleryService, galleries, backendGalleries, systemState, modelLoader, !mi.DisablePredownloadScan, mi.AutoloadBackendGalleries, mi.RequireBackendIntegrity, progressCallback, installOptions, modelName)
151156
if err != nil {
152157
return err
153158
}

core/gallery/describe_variants.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package gallery
2+
3+
// VariantView is one selectable build of an entry, as a client sees it.
4+
//
5+
// It is the read-only mirror of what selection decides at install time, so a
6+
// picker can show the user the same facts the installer acts on rather than a
7+
// second, independently-derived opinion that could disagree with it.
8+
type VariantView struct {
9+
// Model is the name to send back as the install request's `variant`.
10+
Model string `json:"model"`
11+
// Backend is the engine the referenced entry resolves to. A client renders
12+
// it, and it is also the reason Fits may be false on a host whose memory
13+
// would be ample.
14+
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.
18+
MemoryBytes uint64 `json:"memory_bytes,omitempty"`
19+
// Fits reports whether auto-selection would consider this variant on this
20+
// host: its backend can run here and its known footprint is within budget.
21+
// An unknown footprint counts as fitting, exactly as selection treats it.
22+
Fits bool `json:"fits"`
23+
// IsBase marks the entry's own payload. It is always installable and is
24+
// what auto-selection falls back to, which is why it is listed alongside
25+
// the declared variants rather than hidden.
26+
IsBase bool `json:"is_base"`
27+
}
28+
29+
// EntryVariants is the variant surface of a single gallery entry.
30+
type EntryVariants struct {
31+
Variants []VariantView `json:"variants"`
32+
// AutoSelected is the variant auto-selection would install on this host
33+
// right now. Clients show it as the default choice.
34+
AutoSelected string `json:"auto_selected"`
35+
}
36+
37+
// DescribeVariants reports an entry's selectable builds and which one
38+
// auto-selection would currently pick.
39+
//
40+
// It runs the SAME variantOptions + SelectVariant pass the installer runs, so
41+
// the reported auto-selection cannot drift from what installing would actually
42+
// do. The env carries the same probe seam too, so the size shown here and the
43+
// size the installer compares against come from one cache and one round trip.
44+
//
45+
// An entry that declares no variants returns nil, nil WITHOUT touching the
46+
// probe. That is load-bearing: the gallery listing walks entries by the
47+
// thousand and the overwhelming majority declare nothing, so the no-variant
48+
// case must cost nothing at all.
49+
func DescribeVariants(models []*GalleryModel, entry *GalleryModel, env ResolveEnv) (*EntryVariants, error) {
50+
if entry == nil || !entry.HasVariants() {
51+
return nil, nil
52+
}
53+
54+
options, err := variantOptions(models, entry, env)
55+
if err != nil {
56+
return nil, err
57+
}
58+
59+
selection, err := SelectVariant(options, env, "")
60+
if err != nil {
61+
return nil, err
62+
}
63+
64+
views := make([]VariantView, 0, len(options))
65+
for _, o := range options {
66+
memory, known, err := o.EffectiveMemory()
67+
if err != nil {
68+
return nil, err
69+
}
70+
view := VariantView{
71+
Model: o.Variant.Model,
72+
Backend: o.Backend,
73+
// The base is exempt from every filter and always installs, so
74+
// reporting it as anything but fitting would misdescribe it.
75+
Fits: o.IsBase || (env.backendRuns(o.Backend) && (!known || memory <= env.AvailableMemory)),
76+
IsBase: o.IsBase,
77+
}
78+
if known {
79+
view.MemoryBytes = memory
80+
}
81+
views = append(views, view)
82+
}
83+
84+
return &EntryVariants{Variants: views, AutoSelected: selection.Option.Variant.Model}, nil
85+
}

0 commit comments

Comments
 (0)