Files
LocalAI/pkg/mcp/localaitools/inproc/client_test.go
LocalAI [bot] 9565db5f94 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>
2026-06-20 22:38:42 +02:00

127 lines
4.3 KiB
Go

package inproc
import (
"context"
"errors"
"os"
"path/filepath"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services/galleryop"
localaitools "github.com/mudler/LocalAI/pkg/mcp/localaitools"
"github.com/mudler/LocalAI/pkg/system"
)
// Regression spec for the bug we fixed when channel sends were
// unconditional: with a never-read gallery channel and a pre-cancelled
// ctx, InstallModel must surface ctx.Err() instead of blocking forever.
// The same guarantee covers ImportModelURI, DeleteModel, InstallBackend,
// UpgradeBackend — they all share sendModelOp / sendBackendOp.
var _ = Describe("inproc.Client cancellation", func() {
It("InstallModel returns context.Canceled when the gallery channel is never drained", func() {
gs := &galleryop.GalleryService{
// Unbuffered. Nothing reads from it in this spec, so a naive
// send would block the goroutine indefinitely.
ModelGalleryChannel: make(chan galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]),
}
c := &Client{
AppConfig: &config.ApplicationConfig{SystemState: &system.SystemState{Model: system.Model{ModelsPath: GinkgoT().TempDir()}}},
SystemState: &system.SystemState{Model: system.Model{ModelsPath: GinkgoT().TempDir()}},
Gallery: gs,
}
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-cancel: the select must take the ctx.Done branch immediately.
done := make(chan error, 1)
go func() {
_, err := c.InstallModel(ctx, localaitools.InstallModelRequest{ModelName: "x"})
done <- err
}()
var err error
Eventually(done, time.Second).Should(Receive(&err))
Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "got: %v", err)
})
})
var _ = Describe("inproc.Client model aliases", func() {
var (
ctx context.Context
tempDir string
cl *config.ModelConfigLoader
c *Client
seedModel func(name, body string)
)
BeforeEach(func() {
ctx = context.Background()
tempDir = GinkgoT().TempDir()
systemState, err := system.GetSystemState(system.WithModelPath(tempDir))
Expect(err).ToNot(HaveOccurred())
appConfig := config.NewApplicationConfig(config.WithSystemState(systemState))
cl = config.NewModelConfigLoader(tempDir)
// Gallery/model loaders are unused by the alias methods, so nil is fine.
c = New(appConfig, systemState, cl, nil, nil)
seedModel = func(name, body string) {
Expect(os.WriteFile(filepath.Join(tempDir, name+".yaml"), []byte(body), 0644)).To(Succeed())
Expect(cl.LoadModelConfigsFromPath(tempDir)).To(Succeed())
}
})
Describe("ListAliases", func() {
It("returns only configs whose alias field is set", func() {
seedModel("real", "name: real\nbackend: llama-cpp\n")
seedModel("gpt-4", "name: gpt-4\nalias: real\n")
out, err := c.ListAliases(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ConsistOf(localaitools.AliasInfo{Name: "gpt-4", Target: "real"}))
})
It("returns an empty slice when there are no aliases", func() {
seedModel("real", "name: real\nbackend: llama-cpp\n")
out, err := c.ListAliases(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(BeEmpty())
})
})
Describe("SetAlias", func() {
It("creates a new alias config on disk when the name is unused", func() {
seedModel("real", "name: real\nbackend: llama-cpp\n")
Expect(c.SetAlias(ctx, "gpt-4", "real")).To(Succeed())
Expect(filepath.Join(tempDir, "gpt-4.yaml")).To(BeAnExistingFile())
out, err := c.ListAliases(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ConsistOf(localaitools.AliasInfo{Name: "gpt-4", Target: "real"}))
})
It("swaps an existing alias's target in place", func() {
seedModel("real", "name: real\nbackend: llama-cpp\n")
seedModel("other", "name: other\nbackend: llama-cpp\n")
seedModel("gpt-4", "name: gpt-4\nalias: real\n")
Expect(c.SetAlias(ctx, "gpt-4", "other")).To(Succeed())
out, err := c.ListAliases(ctx)
Expect(err).ToNot(HaveOccurred())
Expect(out).To(ConsistOf(localaitools.AliasInfo{Name: "gpt-4", Target: "other"}))
})
It("rejects an alias whose target does not exist", func() {
err := c.SetAlias(ctx, "gpt-4", "missing")
Expect(err).To(HaveOccurred())
Expect(filepath.Join(tempDir, "gpt-4.yaml")).ToNot(BeAnExistingFile())
})
})
})