Skip to content

Commit 9565db5

Browse files
localai-botmudler
andauthored
feat(models): model aliases - redirect a model name to another configured model (#10414)
* feat(config): add model alias field and self-validation Add ModelConfig.Alias (yaml: alias), IsAlias(), and an alias short-circuit at the top of Validate() that rejects self-reference and forbids setting backend/parameters.model on a pure-redirect alias. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(config): resolve and validate model alias targets in the loader Assisted-by: Claude:opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(middleware): resolve model aliases and stamp requested/served identity Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(modeladmin): reject alias configs with invalid targets on create/edit Validate alias targets at create/swap entry points (ImportModelEndpoint, EditYAML, PatchConfig) so a dangling, chained, or disabled alias target is rejected at save time rather than surfacing as a runtime error. Assisted-by: Claude:opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(api): add GET /api/aliases to list model aliases Adds an admin-gated read-only endpoint that lists every model alias config as {name, target} pairs, backed by the loader's existing GetAllModelsConfigs(). Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(mcp): add set_alias and list_aliases tools Expose model-alias management over the LocalAI Assistant MCP surface: list_aliases (read-only, GET /api/aliases) and set_alias (mutating). SetAlias is swap-first: PATCH /api/models/config-json/:name swaps an existing alias's target (validated, non-destructive) and a 404 falls back to POST /models/import to create a fresh {name, alias} config. The inproc client mirrors this via ConfigService.PatchConfig + a create path modeled on ImportModelEndpoint. Deletion reuses delete_model. Assisted-by: Claude:claude-opus-4 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * style(mcp): replace em dashes in alias tool comments Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(config-meta): expose alias as a model-select field Add an 'alias' section to DefaultSections() and an 'alias' field override in DefaultRegistry() so the schema-driven React editor renders the new top-level ModelConfig.Alias field as a model picker in its own section. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): add alias template card and Manage alias badge Add an 'Alias / Routing' template to the create-flow gallery that seeds a minimal name + alias config, and a read-only 'alias -> target' badge on the Manage Models tab. The capabilities row payload does not carry the alias field, so the badge resolves targets from GET /api/aliases looked up by name. Assisted-by: Claude:claude-opus-4 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: document model aliases Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(swagger): regenerate for GET /api/aliases Adds the /api/aliases path and AliasInfo schema generated from the ListAliasesEndpoint annotation. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(localai): check os.RemoveAll error in aliases_test Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix: correct alias conversion docs and advertise /api/aliases in instructions Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(mcp): write alias config 0600 to satisfy gosec G306 The inproc createAlias path wrote the alias YAML with 0644, which gosec flags as a new G306 finding on the PR. The LocalAI process is the sole reader/writer of model configs, so 0600 is correct and keeps the scan clean. Assisted-by: Claude:claude-opus-4-8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent e19c43c commit 9565db5

39 files changed

Lines changed: 1098 additions & 2 deletions

core/config/meta/registry.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,15 @@ func DefaultRegistry() map[string]FieldMetaOverride {
286286
Order: 45,
287287
},
288288

289+
// --- Alias ---
290+
"alias": {
291+
Section: "alias",
292+
Label: "Alias target",
293+
Description: "Redirect all traffic for this model to another configured model. When set, every other field on this config is ignored and requests are served by the target model.",
294+
Component: "model-select",
295+
Order: 0,
296+
},
297+
289298
// --- Pipeline ---
290299
"pipeline.llm": {
291300
Section: "pipeline",

core/config/meta/registry_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package meta_test
2+
3+
import (
4+
"github.com/mudler/LocalAI/core/config/meta"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
var _ = Describe("alias field metadata", func() {
11+
It("registers the alias field as a model-select in the alias section", func() {
12+
reg := meta.DefaultRegistry()
13+
f, ok := reg["alias"]
14+
Expect(ok).To(BeTrue(), "alias field should have a registry override")
15+
Expect(f.Section).To(Equal("alias"))
16+
Expect(f.Component).To(Equal("model-select"))
17+
})
18+
19+
It("defines an alias section", func() {
20+
var found bool
21+
for _, s := range meta.DefaultSections() {
22+
if s.ID == "alias" {
23+
found = true
24+
}
25+
}
26+
Expect(found).To(BeTrue(), "DefaultSections should include an alias section")
27+
})
28+
})

core/config/meta/types.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ type FieldMetaOverride struct {
6969
func DefaultSections() []Section {
7070
return []Section{
7171
{ID: "general", Label: "General", Icon: "settings", Order: 0},
72+
{ID: "alias", Label: "Alias", Icon: "git-merge", Order: 5},
7273
{ID: "llm", Label: "LLM", Icon: "cpu", Order: 10},
7374
{ID: "parameters", Label: "Parameters", Icon: "sliders", Order: 20},
7475
{ID: "templates", Label: "Templates", Icon: "file-text", Order: 30},

core/config/model_config.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,12 @@ type ModelConfig struct {
3737
schema.PredictionOptions `yaml:"parameters,omitempty" json:"parameters,omitempty"`
3838
Name string `yaml:"name,omitempty" json:"name,omitempty"`
3939

40+
// Alias, when set, makes this config a pure redirect: every request for
41+
// Name is served by the model named here. All other fields are ignored.
42+
// The target must be an existing, non-alias model (enforced at load and
43+
// at create/swap time). See docs/content for Model Aliases.
44+
Alias string `yaml:"alias,omitempty" json:"alias,omitempty"`
45+
4046
F16 *bool `yaml:"f16,omitempty" json:"f16,omitempty"`
4147
Threads *int `yaml:"threads,omitempty" json:"threads,omitempty"`
4248
Debug *bool `yaml:"debug,omitempty" json:"debug,omitempty"`
@@ -391,6 +397,10 @@ func (c *ModelConfig) HasRouter() bool {
391397
return len(c.Router.Candidates) > 0
392398
}
393399

400+
// IsAlias reports whether this config is a pure redirect to another model.
401+
// Value receiver so it is callable on non-addressable config values too.
402+
func (c ModelConfig) IsAlias() bool { return c.Alias != "" }
403+
394404
// @Description PII filtering configuration. PII redaction is per-model so
395405
// that local models don't pay the latency or behaviour change of regex
396406
// scanning, while cloud-bound traffic (cloud-proxy backend) can default to
@@ -1248,6 +1258,22 @@ func (cfg *ModelConfig) SetDefaults(opts ...ConfigLoaderOption) {
12481258
}
12491259

12501260
func (c *ModelConfig) Validate() (bool, error) {
1261+
// An alias is a pure redirect: validate only its own shape here. Target
1262+
// existence and the no-chain rule need the full config set, so the loader
1263+
// (load-time) and the create/swap endpoints enforce those.
1264+
if c.IsAlias() {
1265+
if c.Name == "" {
1266+
return false, fmt.Errorf("alias config requires a name")
1267+
}
1268+
if c.Alias == c.Name {
1269+
return false, fmt.Errorf("alias %q cannot point to itself", c.Name)
1270+
}
1271+
if c.Backend != "" || c.Model != "" {
1272+
return false, fmt.Errorf("alias config %q must not set backend or parameters.model: an alias is a pure redirect", c.Name)
1273+
}
1274+
return true, nil
1275+
}
1276+
12511277
downloadedFileNames := []string{}
12521278
for _, f := range c.DownloadFiles {
12531279
downloadedFileNames = append(downloadedFileNames, f.Filename)

core/config/model_config_loader.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,44 @@ func (bcl *ModelConfigLoader) UpdateModelConfig(m string, updater func(*ModelCon
294294
}
295295
}
296296

297+
// ResolveAlias follows a one-hop alias to its target config. Returns
298+
// (resolved, wasAlias, err). Non-alias configs return (cfg, false, nil)
299+
// unchanged. Strict: the target must exist and must not itself be an alias
300+
// (chains are rejected). The returned config is a copy of the target.
301+
func (bcl *ModelConfigLoader) ResolveAlias(cfg *ModelConfig) (*ModelConfig, bool, error) {
302+
if cfg == nil || !cfg.IsAlias() {
303+
return cfg, false, nil
304+
}
305+
target, exists := bcl.GetModelConfig(cfg.Alias)
306+
if !exists {
307+
return nil, true, fmt.Errorf("alias %q points to unknown model %q", cfg.Name, cfg.Alias)
308+
}
309+
if target.IsAlias() {
310+
return nil, true, fmt.Errorf("alias %q points to another alias %q (chains are not allowed)", cfg.Name, cfg.Alias)
311+
}
312+
return &target, true, nil
313+
}
314+
315+
// ValidateAliasTarget checks an alias config's target at create/swap time:
316+
// the target must exist, must not be an alias, and must not be disabled.
317+
// Returns nil for non-alias configs.
318+
func (bcl *ModelConfigLoader) ValidateAliasTarget(cfg *ModelConfig) error {
319+
if cfg == nil || !cfg.IsAlias() {
320+
return nil
321+
}
322+
target, exists := bcl.GetModelConfig(cfg.Alias)
323+
if !exists {
324+
return fmt.Errorf("alias target %q does not exist", cfg.Alias)
325+
}
326+
if target.IsAlias() {
327+
return fmt.Errorf("alias target %q is itself an alias (chains are not allowed)", cfg.Alias)
328+
}
329+
if target.IsDisabled() {
330+
return fmt.Errorf("alias target %q is disabled", cfg.Alias)
331+
}
332+
return nil
333+
}
334+
297335
// Preload prepare models if they are not local but url or huggingface repositories
298336
func (bcl *ModelConfigLoader) Preload(modelPath string) error {
299337
bcl.Lock()
@@ -475,5 +513,21 @@ func (bcl *ModelConfigLoader) LoadModelConfigsFromPath(path string, opts ...Conf
475513
}
476514
}
477515

516+
// Surface aliases whose targets are missing or themselves aliases. These
517+
// resolve to a clear request-time error; warning here gives operators
518+
// visibility without failing startup.
519+
for name, c := range bcl.configs {
520+
if !c.IsAlias() {
521+
continue
522+
}
523+
target, ok := bcl.configs[c.Alias]
524+
switch {
525+
case !ok:
526+
xlog.Warn("alias points to unknown model", "alias", name, "target", c.Alias)
527+
case target.IsAlias():
528+
xlog.Warn("alias points to another alias (chains are not allowed)", "alias", name, "target", c.Alias)
529+
}
530+
}
531+
478532
return nil
479533
}

core/config/model_config_loader_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,51 @@ var _ = Describe("ModelConfigLoader.GetModelsConflictingWith", func() {
6161
Expect(bcl.GetModelsConflictingWith("a")).To(ConsistOf("b"))
6262
})
6363
})
64+
65+
var _ = Describe("ModelConfigLoader alias resolution", func() {
66+
var loader *ModelConfigLoader
67+
68+
BeforeEach(func() {
69+
loader = NewModelConfigLoader("")
70+
loader.configs["real"] = ModelConfig{Name: "real", Backend: "llama-cpp"}
71+
loader.configs["gpt-4"] = ModelConfig{Name: "gpt-4", Alias: "real"}
72+
loader.configs["chain"] = ModelConfig{Name: "chain", Alias: "gpt-4"}
73+
loader.configs["dangling"] = ModelConfig{Name: "dangling", Alias: "nope"}
74+
})
75+
76+
It("returns non-alias configs unchanged", func() {
77+
cfg := loader.configs["real"]
78+
got, was, err := loader.ResolveAlias(&cfg)
79+
Expect(err).ToNot(HaveOccurred())
80+
Expect(was).To(BeFalse())
81+
Expect(got.Name).To(Equal("real"))
82+
})
83+
84+
It("resolves an alias to its target", func() {
85+
cfg := loader.configs["gpt-4"]
86+
got, was, err := loader.ResolveAlias(&cfg)
87+
Expect(err).ToNot(HaveOccurred())
88+
Expect(was).To(BeTrue())
89+
Expect(got.Name).To(Equal("real"))
90+
})
91+
92+
It("rejects an alias chain", func() {
93+
cfg := loader.configs["chain"]
94+
_, was, err := loader.ResolveAlias(&cfg)
95+
Expect(was).To(BeTrue())
96+
Expect(err).To(MatchError(ContainSubstring("chains are not allowed")))
97+
})
98+
99+
It("rejects a dangling alias", func() {
100+
cfg := loader.configs["dangling"]
101+
_, _, err := loader.ResolveAlias(&cfg)
102+
Expect(err).To(MatchError(ContainSubstring("unknown model")))
103+
})
104+
105+
It("ValidateAliasTarget passes for a real target and fails for a chain", func() {
106+
good := loader.configs["gpt-4"]
107+
Expect(loader.ValidateAliasTarget(&good)).ToNot(HaveOccurred())
108+
bad := loader.configs["chain"]
109+
Expect(loader.ValidateAliasTarget(&bad)).To(MatchError(ContainSubstring("itself an alias")))
110+
})
111+
})

core/config/model_config_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -787,3 +787,32 @@ var _ = Describe("pattern detector config", func() {
787787
Expect(err).To(MatchError(ContainSubstring("pattern \"EMAILish\"")))
788788
})
789789
})
790+
791+
var _ = Describe("ModelConfig alias", func() {
792+
It("reports IsAlias when alias is set", func() {
793+
c := ModelConfig{Name: "gpt-4", Alias: "my-llama-3"}
794+
Expect(c.IsAlias()).To(BeTrue())
795+
Expect(ModelConfig{Name: "real"}.IsAlias()).To(BeFalse())
796+
})
797+
798+
It("validates a minimal alias config", func() {
799+
c := ModelConfig{Name: "gpt-4", Alias: "my-llama-3"}
800+
ok, err := c.Validate()
801+
Expect(err).ToNot(HaveOccurred())
802+
Expect(ok).To(BeTrue())
803+
})
804+
805+
It("rejects an alias pointing to itself", func() {
806+
c := ModelConfig{Name: "loop", Alias: "loop"}
807+
ok, err := c.Validate()
808+
Expect(ok).To(BeFalse())
809+
Expect(err).To(MatchError(ContainSubstring("itself")))
810+
})
811+
812+
It("rejects an alias that also sets a backend", func() {
813+
c := ModelConfig{Name: "gpt-4", Alias: "my-llama-3", Backend: "llama-cpp"}
814+
ok, err := c.Validate()
815+
Expect(ok).To(BeFalse())
816+
Expect(err).To(MatchError(ContainSubstring("pure redirect")))
817+
})
818+
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package localai
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/labstack/echo/v4"
7+
"github.com/mudler/LocalAI/core/config"
8+
)
9+
10+
// AliasInfo is one alias -> target pair.
11+
type AliasInfo struct {
12+
Name string `json:"name"`
13+
Target string `json:"target"`
14+
}
15+
16+
// ListAliasesEndpoint returns every configured model alias and its target.
17+
//
18+
// @Summary List model aliases
19+
// @Tags models
20+
// @Success 200 {array} AliasInfo
21+
// @Router /api/aliases [get]
22+
func ListAliasesEndpoint(cl *config.ModelConfigLoader) echo.HandlerFunc {
23+
return func(c echo.Context) error {
24+
// Non-nil so an empty result marshals as [] rather than null.
25+
out := []AliasInfo{}
26+
for _, cfg := range cl.GetAllModelsConfigs() {
27+
if cfg.IsAlias() {
28+
out = append(out, AliasInfo{Name: cfg.Name, Target: cfg.Alias})
29+
}
30+
}
31+
return c.JSON(http.StatusOK, out)
32+
}
33+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package localai_test
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/labstack/echo/v4"
10+
"github.com/mudler/LocalAI/core/config"
11+
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
12+
. "github.com/onsi/ginkgo/v2"
13+
. "github.com/onsi/gomega"
14+
)
15+
16+
var _ = Describe("ListAliasesEndpoint", func() {
17+
var tempDir string
18+
19+
BeforeEach(func() {
20+
var err error
21+
tempDir, err = os.MkdirTemp("", "localai-aliases-test")
22+
Expect(err).ToNot(HaveOccurred())
23+
})
24+
AfterEach(func() {
25+
_ = os.RemoveAll(tempDir)
26+
})
27+
28+
It("returns only alias configs as name/target pairs", func() {
29+
// Seed one real model and one alias pointing at it.
30+
Expect(os.WriteFile(
31+
filepath.Join(tempDir, "real.yaml"),
32+
[]byte("name: real\nbackend: llama-cpp\nmodel: foo\n"),
33+
0644,
34+
)).To(Succeed())
35+
Expect(os.WriteFile(
36+
filepath.Join(tempDir, "gpt-4.yaml"),
37+
[]byte("name: gpt-4\nalias: real\n"),
38+
0644,
39+
)).To(Succeed())
40+
41+
loader := config.NewModelConfigLoader(tempDir)
42+
Expect(loader.LoadModelConfigsFromPath(tempDir)).To(Succeed())
43+
44+
app := echo.New()
45+
app.GET("/api/aliases", ListAliasesEndpoint(loader))
46+
47+
req := httptest.NewRequest("GET", "/api/aliases", nil)
48+
rec := httptest.NewRecorder()
49+
app.ServeHTTP(rec, req)
50+
51+
Expect(rec.Code).To(Equal(http.StatusOK))
52+
Expect(rec.Body.String()).To(ContainSubstring(`"name":"gpt-4"`))
53+
Expect(rec.Body.String()).To(ContainSubstring(`"target":"real"`))
54+
// The real model must not appear as an alias entry.
55+
Expect(rec.Body.String()).ToNot(ContainSubstring(`"name":"real"`))
56+
})
57+
})

core/http/endpoints/localai/import_model.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,12 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
181181
return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: msg})
182182
}
183183

184+
// Reject aliases whose target is missing, chained, or disabled so a
185+
// dangling alias can't be persisted and surface as a runtime error later.
186+
if err := cl.ValidateAliasTarget(&modelConfig); err != nil {
187+
return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: err.Error()})
188+
}
189+
184190
// Create the configuration file
185191
configPath := filepath.Join(appConfig.SystemState.Model.ModelsPath, modelConfig.Name+".yaml")
186192
if err := utils.VerifyPath(modelConfig.Name+".yaml", appConfig.SystemState.Model.ModelsPath); err != nil {

0 commit comments

Comments
 (0)