Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions core/config/meta/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,15 @@ func DefaultRegistry() map[string]FieldMetaOverride {
Order: 45,
},

// --- Alias ---
"alias": {
Section: "alias",
Label: "Alias target",
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.",
Component: "model-select",
Order: 0,
},

// --- Pipeline ---
"pipeline.llm": {
Section: "pipeline",
Expand Down
28 changes: 28 additions & 0 deletions core/config/meta/registry_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package meta_test

import (
"github.com/mudler/LocalAI/core/config/meta"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("alias field metadata", func() {
It("registers the alias field as a model-select in the alias section", func() {
reg := meta.DefaultRegistry()
f, ok := reg["alias"]
Expect(ok).To(BeTrue(), "alias field should have a registry override")
Expect(f.Section).To(Equal("alias"))
Expect(f.Component).To(Equal("model-select"))
})

It("defines an alias section", func() {
var found bool
for _, s := range meta.DefaultSections() {
if s.ID == "alias" {
found = true
}
}
Expect(found).To(BeTrue(), "DefaultSections should include an alias section")
})
})
1 change: 1 addition & 0 deletions core/config/meta/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ type FieldMetaOverride struct {
func DefaultSections() []Section {
return []Section{
{ID: "general", Label: "General", Icon: "settings", Order: 0},
{ID: "alias", Label: "Alias", Icon: "git-merge", Order: 5},
{ID: "llm", Label: "LLM", Icon: "cpu", Order: 10},
{ID: "parameters", Label: "Parameters", Icon: "sliders", Order: 20},
{ID: "templates", Label: "Templates", Icon: "file-text", Order: 30},
Expand Down
26 changes: 26 additions & 0 deletions core/config/model_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ type ModelConfig struct {
schema.PredictionOptions `yaml:"parameters,omitempty" json:"parameters,omitempty"`
Name string `yaml:"name,omitempty" json:"name,omitempty"`

// Alias, when set, makes this config a pure redirect: every request for
// Name is served by the model named here. All other fields are ignored.
// The target must be an existing, non-alias model (enforced at load and
// at create/swap time). See docs/content for Model Aliases.
Alias string `yaml:"alias,omitempty" json:"alias,omitempty"`

F16 *bool `yaml:"f16,omitempty" json:"f16,omitempty"`
Threads *int `yaml:"threads,omitempty" json:"threads,omitempty"`
Debug *bool `yaml:"debug,omitempty" json:"debug,omitempty"`
Expand Down Expand Up @@ -391,6 +397,10 @@ func (c *ModelConfig) HasRouter() bool {
return len(c.Router.Candidates) > 0
}

// IsAlias reports whether this config is a pure redirect to another model.
// Value receiver so it is callable on non-addressable config values too.
func (c ModelConfig) IsAlias() bool { return c.Alias != "" }

// @Description PII filtering configuration. PII redaction is per-model so
// that local models don't pay the latency or behaviour change of regex
// scanning, while cloud-bound traffic (cloud-proxy backend) can default to
Expand Down Expand Up @@ -1243,6 +1253,22 @@ func (cfg *ModelConfig) SetDefaults(opts ...ConfigLoaderOption) {
}

func (c *ModelConfig) Validate() (bool, error) {
// An alias is a pure redirect: validate only its own shape here. Target
// existence and the no-chain rule need the full config set, so the loader
// (load-time) and the create/swap endpoints enforce those.
if c.IsAlias() {
if c.Name == "" {
return false, fmt.Errorf("alias config requires a name")
}
if c.Alias == c.Name {
return false, fmt.Errorf("alias %q cannot point to itself", c.Name)
}
if c.Backend != "" || c.Model != "" {
return false, fmt.Errorf("alias config %q must not set backend or parameters.model: an alias is a pure redirect", c.Name)
}
return true, nil
}

downloadedFileNames := []string{}
for _, f := range c.DownloadFiles {
downloadedFileNames = append(downloadedFileNames, f.Filename)
Expand Down
54 changes: 54 additions & 0 deletions core/config/model_config_loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,44 @@ func (bcl *ModelConfigLoader) UpdateModelConfig(m string, updater func(*ModelCon
}
}

// ResolveAlias follows a one-hop alias to its target config. Returns
// (resolved, wasAlias, err). Non-alias configs return (cfg, false, nil)
// unchanged. Strict: the target must exist and must not itself be an alias
// (chains are rejected). The returned config is a copy of the target.
func (bcl *ModelConfigLoader) ResolveAlias(cfg *ModelConfig) (*ModelConfig, bool, error) {
if cfg == nil || !cfg.IsAlias() {
return cfg, false, nil
}
target, exists := bcl.GetModelConfig(cfg.Alias)
if !exists {
return nil, true, fmt.Errorf("alias %q points to unknown model %q", cfg.Name, cfg.Alias)
}
if target.IsAlias() {
return nil, true, fmt.Errorf("alias %q points to another alias %q (chains are not allowed)", cfg.Name, cfg.Alias)
}
return &target, true, nil
}

// ValidateAliasTarget checks an alias config's target at create/swap time:
// the target must exist, must not be an alias, and must not be disabled.
// Returns nil for non-alias configs.
func (bcl *ModelConfigLoader) ValidateAliasTarget(cfg *ModelConfig) error {
if cfg == nil || !cfg.IsAlias() {
return nil
}
target, exists := bcl.GetModelConfig(cfg.Alias)
if !exists {
return fmt.Errorf("alias target %q does not exist", cfg.Alias)
}
if target.IsAlias() {
return fmt.Errorf("alias target %q is itself an alias (chains are not allowed)", cfg.Alias)
}
if target.IsDisabled() {
return fmt.Errorf("alias target %q is disabled", cfg.Alias)
}
return nil
}

// Preload prepare models if they are not local but url or huggingface repositories
func (bcl *ModelConfigLoader) Preload(modelPath string) error {
bcl.Lock()
Expand Down Expand Up @@ -475,5 +513,21 @@ func (bcl *ModelConfigLoader) LoadModelConfigsFromPath(path string, opts ...Conf
}
}

// Surface aliases whose targets are missing or themselves aliases. These
// resolve to a clear request-time error; warning here gives operators
// visibility without failing startup.
for name, c := range bcl.configs {
if !c.IsAlias() {
continue
}
target, ok := bcl.configs[c.Alias]
switch {
case !ok:
xlog.Warn("alias points to unknown model", "alias", name, "target", c.Alias)
case target.IsAlias():
xlog.Warn("alias points to another alias (chains are not allowed)", "alias", name, "target", c.Alias)
}
}

return nil
}
48 changes: 48 additions & 0 deletions core/config/model_config_loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,51 @@ var _ = Describe("ModelConfigLoader.GetModelsConflictingWith", func() {
Expect(bcl.GetModelsConflictingWith("a")).To(ConsistOf("b"))
})
})

var _ = Describe("ModelConfigLoader alias resolution", func() {
var loader *ModelConfigLoader

BeforeEach(func() {
loader = NewModelConfigLoader("")
loader.configs["real"] = ModelConfig{Name: "real", Backend: "llama-cpp"}
loader.configs["gpt-4"] = ModelConfig{Name: "gpt-4", Alias: "real"}
loader.configs["chain"] = ModelConfig{Name: "chain", Alias: "gpt-4"}
loader.configs["dangling"] = ModelConfig{Name: "dangling", Alias: "nope"}
})

It("returns non-alias configs unchanged", func() {
cfg := loader.configs["real"]
got, was, err := loader.ResolveAlias(&cfg)
Expect(err).ToNot(HaveOccurred())
Expect(was).To(BeFalse())
Expect(got.Name).To(Equal("real"))
})

It("resolves an alias to its target", func() {
cfg := loader.configs["gpt-4"]
got, was, err := loader.ResolveAlias(&cfg)
Expect(err).ToNot(HaveOccurred())
Expect(was).To(BeTrue())
Expect(got.Name).To(Equal("real"))
})

It("rejects an alias chain", func() {
cfg := loader.configs["chain"]
_, was, err := loader.ResolveAlias(&cfg)
Expect(was).To(BeTrue())
Expect(err).To(MatchError(ContainSubstring("chains are not allowed")))
})

It("rejects a dangling alias", func() {
cfg := loader.configs["dangling"]
_, _, err := loader.ResolveAlias(&cfg)
Expect(err).To(MatchError(ContainSubstring("unknown model")))
})

It("ValidateAliasTarget passes for a real target and fails for a chain", func() {
good := loader.configs["gpt-4"]
Expect(loader.ValidateAliasTarget(&good)).ToNot(HaveOccurred())
bad := loader.configs["chain"]
Expect(loader.ValidateAliasTarget(&bad)).To(MatchError(ContainSubstring("itself an alias")))
})
})
29 changes: 29 additions & 0 deletions core/config/model_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -787,3 +787,32 @@ var _ = Describe("pattern detector config", func() {
Expect(err).To(MatchError(ContainSubstring("pattern \"EMAILish\"")))
})
})

var _ = Describe("ModelConfig alias", func() {
It("reports IsAlias when alias is set", func() {
c := ModelConfig{Name: "gpt-4", Alias: "my-llama-3"}
Expect(c.IsAlias()).To(BeTrue())
Expect(ModelConfig{Name: "real"}.IsAlias()).To(BeFalse())
})

It("validates a minimal alias config", func() {
c := ModelConfig{Name: "gpt-4", Alias: "my-llama-3"}
ok, err := c.Validate()
Expect(err).ToNot(HaveOccurred())
Expect(ok).To(BeTrue())
})

It("rejects an alias pointing to itself", func() {
c := ModelConfig{Name: "loop", Alias: "loop"}
ok, err := c.Validate()
Expect(ok).To(BeFalse())
Expect(err).To(MatchError(ContainSubstring("itself")))
})

It("rejects an alias that also sets a backend", func() {
c := ModelConfig{Name: "gpt-4", Alias: "my-llama-3", Backend: "llama-cpp"}
ok, err := c.Validate()
Expect(ok).To(BeFalse())
Expect(err).To(MatchError(ContainSubstring("pure redirect")))
})
})
33 changes: 33 additions & 0 deletions core/http/endpoints/localai/aliases.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package localai

import (
"net/http"

"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
)

// AliasInfo is one alias -> target pair.
type AliasInfo struct {
Name string `json:"name"`
Target string `json:"target"`
}

// ListAliasesEndpoint returns every configured model alias and its target.
//
// @Summary List model aliases
// @Tags models
// @Success 200 {array} AliasInfo
// @Router /api/aliases [get]
func ListAliasesEndpoint(cl *config.ModelConfigLoader) echo.HandlerFunc {
return func(c echo.Context) error {
// Non-nil so an empty result marshals as [] rather than null.
out := []AliasInfo{}
for _, cfg := range cl.GetAllModelsConfigs() {
if cfg.IsAlias() {
out = append(out, AliasInfo{Name: cfg.Name, Target: cfg.Alias})
}
}
return c.JSON(http.StatusOK, out)
}
}
57 changes: 57 additions & 0 deletions core/http/endpoints/localai/aliases_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package localai_test

import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"

"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("ListAliasesEndpoint", func() {
var tempDir string

BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "localai-aliases-test")
Expect(err).ToNot(HaveOccurred())
})
AfterEach(func() {
_ = os.RemoveAll(tempDir)
})

It("returns only alias configs as name/target pairs", func() {
// Seed one real model and one alias pointing at it.
Expect(os.WriteFile(
filepath.Join(tempDir, "real.yaml"),
[]byte("name: real\nbackend: llama-cpp\nmodel: foo\n"),
0644,
)).To(Succeed())
Expect(os.WriteFile(
filepath.Join(tempDir, "gpt-4.yaml"),
[]byte("name: gpt-4\nalias: real\n"),
0644,
)).To(Succeed())

loader := config.NewModelConfigLoader(tempDir)
Expect(loader.LoadModelConfigsFromPath(tempDir)).To(Succeed())

app := echo.New()
app.GET("/api/aliases", ListAliasesEndpoint(loader))

req := httptest.NewRequest("GET", "/api/aliases", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)

Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Body.String()).To(ContainSubstring(`"name":"gpt-4"`))
Expect(rec.Body.String()).To(ContainSubstring(`"target":"real"`))
// The real model must not appear as an alias entry.
Expect(rec.Body.String()).ToNot(ContainSubstring(`"name":"real"`))
})
})
6 changes: 6 additions & 0 deletions core/http/endpoints/localai/import_model.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,12 @@ func ImportModelEndpoint(cl *config.ModelConfigLoader, appConfig *config.Applica
return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: msg})
}

// Reject aliases whose target is missing, chained, or disabled so a
// dangling alias can't be persisted and surface as a runtime error later.
if err := cl.ValidateAliasTarget(&modelConfig); err != nil {
return c.JSON(http.StatusBadRequest, ModelResponse{Success: false, Error: err.Error()})
}

// Create the configuration file
configPath := filepath.Join(appConfig.SystemState.Model.ModelsPath, modelConfig.Name+".yaml")
if err := utils.VerifyPath(modelConfig.Name+".yaml", appConfig.SystemState.Model.ModelsPath); err != nil {
Expand Down
6 changes: 6 additions & 0 deletions core/http/endpoints/mcp/localai_assistant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ func (stubClient) EditModelConfig(_ context.Context, _ string, _ map[string]any)
return nil
}
func (stubClient) ReloadModels(_ context.Context) error { return nil }
func (stubClient) SetAlias(_ context.Context, _, _ string) error {
return nil
}
func (stubClient) ListAliases(_ context.Context) ([]localaitools.AliasInfo, error) {
return nil, nil
}
func (stubClient) ListBackends(_ context.Context) ([]localaitools.Backend, error) {
return []localaitools.Backend{{Name: "stub-backend", Installed: true}}, nil
}
Expand Down
21 changes: 21 additions & 0 deletions core/http/middleware/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,27 @@ func (re *RequestExtractor) SetModelAndConfig(initializer func() schema.LocalAIR
}
}

// Resolve a model alias to its target before the disabled check and
// before storing MODEL_CONFIG, so every modality (chat, embeddings,
// tts, image, ...) inherits redirection. The response keeps echoing
// the alias name (input.ModelName is left unchanged); usage accounting
// records requested=alias / served=target.
if cfg != nil && cfg.IsAlias() {
resolved, _, aliasErr := re.modelConfigLoader.ResolveAlias(cfg)
if aliasErr != nil {
return c.JSON(http.StatusBadRequest, schema.ErrorResponse{
Error: &schema.APIError{
Message: aliasErr.Error(),
Code: http.StatusBadRequest,
Type: "invalid_request_error",
},
})
}
c.Set(ContextKeyRequestedModel, modelName)
c.Set(ContextKeyServedModel, resolved.Name)
cfg = resolved
}

// Check if the model is disabled
if cfg != nil && cfg.IsDisabled() {
return c.JSON(http.StatusForbidden, schema.ErrorResponse{
Expand Down
Loading
Loading