feat(ui): resolve alias models fully in the capabilities listing

An alias entry now takes its capabilities, backend, MCP, voice cloning,
upscale scale, 3D operations and loaded state from the model it points
to, and keeps its own name, disabled and pinned state. An alias that
cannot be resolved (unknown target or a chain) is logged at warn and
listed with alias_of and alias_error instead of vanishing.

The chat model checker refuses a broken alias when it is saved.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 08:40:07 +00:00
1 parent fc087c13e8
commit 688b12a038
4 files changed
+119 -37

No files matched your search

+21 -4
View File
@@ -7,6 +7,7 @@ import (
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/application"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/auth"
studioendpoint "github.com/mudler/LocalAI/core/http/endpoints/studio"
"github.com/mudler/LocalAI/core/schema"
@@ -14,13 +15,15 @@ import (
)
// NewStudioModelChecker says whether a user may start a conversation with a
// model: it must be a model this instance has, and the user's model allowlist,
// when they have one, must include it. The chat route checks the allowlist again
// model: it must be a model this instance has (an alias counts when its target
// resolves), and the user's model allowlist, when they have one, must include
// the name they chose. The allowlist is matched against that name, an alias
// or not, exactly as the request middleware does. The chat route checks the allowlist again
// on every request, so this only gives an early, clear answer.
func NewStudioModelChecker(app *application.Application) studioendpoint.ModelChecker {
return studioendpoint.ModelCheckerFunc(func(_ context.Context, user *auth.User, model string) error {
if _, ok := app.ModelConfigLoader().GetModelConfig(model); !ok {
return studioendpoint.ErrUnknownModel
if err := checkStudioModelResolves(app.ModelConfigLoader(), model); err != nil {
return err
}
if db := app.AuthDB(); db != nil && user != nil && !auth.IsModelAllowed(db, user, model) {
return studioendpoint.ErrModelNotAllowed
@@ -29,6 +32,20 @@ func NewStudioModelChecker(app *application.Application) studioendpoint.ModelChe
})
}
// checkStudioModelResolves rejects a model with no config and an alias that
// points nowhere, so a broken alias is refused when it is saved and not when
// the first message fails.
func checkStudioModelResolves(cl *config.ModelConfigLoader, model string) error {
cfg, ok := cl.GetModelConfig(model)
if !ok {
return studioendpoint.ErrUnknownModel
}
if _, _, err := cl.ResolveAlias(&cfg); err != nil {
return studioendpoint.ErrUnknownModel
}
return nil
}
// RegisterStudioRoutes registers the chats API. Without a service (the chats
// are switched off or could not start) every /api/chats, /api/workspace and
// /api/agent path answers 404 with the reason, so a client sees "off" and not a missing route. Authentication and
@@ -0,0 +1,34 @@
package routes
import (
"os"
"path/filepath"
"github.com/mudler/LocalAI/core/config"
studioendpoint "github.com/mudler/LocalAI/core/http/endpoints/studio"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("studio model checker", func() {
It("accepts a resolvable alias and refuses unknown, dangling and chained ones", func() {
dir := GinkgoT().TempDir()
cl := config.NewModelConfigLoader(dir)
for name, body := range map[string]string{
"target": "backend: llama-cpp\n",
"alias": "alias: target\n",
"dangling": "alias: missing\n",
"chain": "alias: alias\n",
} {
path := filepath.Join(dir, name+".yaml")
Expect(os.WriteFile(path, []byte("name: "+name+"\n"+body), 0600)).To(Succeed())
Expect(cl.ReadModelConfig(path)).To(Succeed())
}
Expect(checkStudioModelResolves(cl, "target")).To(Succeed())
Expect(checkStudioModelResolves(cl, "alias")).To(Succeed())
for _, name := range []string{"nope", "dangling", "chain"} {
Expect(checkStudioModelResolves(cl, name)).To(MatchError(studioendpoint.ErrUnknownModel), name)
}
})
})
+27 -17
View File
@@ -850,6 +850,12 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
// `mcp` feature. Server names and commands stay behind
// /v1/mcp/servers/:model, which is gated by that feature.
MCP bool `json:"mcp,omitempty"`
// AliasOf names the model an alias entry resolves to. AliasError
// is set instead of dropping the entry when the alias cannot be
// resolved (unknown target or a chain), so a picker can show it
// as broken rather than hiding it.
AliasOf string `json:"alias_of,omitempty"`
AliasError string `json:"alias_error,omitempty"`
}
// Join with the node registry when we have one (distributed mode). A
@@ -882,24 +888,28 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
seen := make(map[string]bool, len(modelConfigs)+len(modelsWithoutConfig))
for _, cfg := range modelConfigs {
seen[cfg.Name] = true
// Resolve only capability flags: the alias keeps its own ID and
// management metadata, including disabled state and access filtering.
capabilities := []string{}
if effective, _, err := cl.ResolveAlias(&cfg); err == nil {
capabilities = effective.KnownUsecaseStrings
// An alias keeps its own ID, disabled and pinned state (they are
// what the owner manages), but everything that describes what the
// model can do comes from the model that serves it.
effective := &cfg
entry := modelCapability{ID: cfg.Name, Disabled: cfg.IsDisabled(), Pinned: cfg.IsPinned()}
if resolved, isAlias, err := cl.ResolveAlias(&cfg); err != nil {
xlog.Warn("Model alias cannot be resolved", "alias", cfg.Name, "target", cfg.Alias, "error", err)
entry.AliasOf, entry.AliasError = cfg.Alias, err.Error()
entry.Capabilities = []string{}
result = append(result, entry)
continue
} else if isAlias {
effective, entry.AliasOf = resolved, cfg.Alias
}
result = append(result, modelCapability{
ID: cfg.Name,
Capabilities: capabilities,
UpscaleScale: config.UpscaleScaleFromOptions(cfg),
ThreeDOperations: cfg.ThreeDOperations(),
Backend: cfg.Backend,
Disabled: cfg.IsDisabled(),
Pinned: cfg.IsPinned(),
VoiceCloning: config.VoiceCloningForModel(&cfg),
LoadedOn: loadedByModel[cfg.Name],
MCP: !appConfig.DisableMCP && cfg.MCP.HasMCPServers(),
})
entry.Capabilities = effective.KnownUsecaseStrings
entry.UpscaleScale = config.UpscaleScaleFromOptions(*effective)
entry.ThreeDOperations = effective.ThreeDOperations()
entry.Backend = effective.Backend
entry.VoiceCloning = config.VoiceCloningForModel(effective)
entry.LoadedOn = loadedByModel[effective.Name]
entry.MCP = !appConfig.DisableMCP && effective.MCP.HasMCPServers()
result = append(result, entry)
}
for _, name := range modelsWithoutConfig {
seen[name] = true
+37 -16
View File
@@ -27,6 +27,8 @@ type capabilityEntry struct {
Disabled bool `json:"disabled"`
Pinned bool `json:"pinned"`
MCP bool `json:"mcp"`
AliasOf string `json:"alias_of"`
AliasError string `json:"alias_error"`
}
// capabilitiesOf writes one model config per entry (name -> YAML after the
@@ -82,30 +84,49 @@ var _ = Describe("UI model capabilities", func() {
}
})
It("gives a valid alias the capabilities of its target and nothing else", func() {
It("describes a valid alias through its target and keeps its own management state", func() {
data := capabilitiesOf(map[string]string{
"target": "backend: llama-cpp\nknown_usecases: [chat, vision]\npinned: true\n",
"alias": "alias: target\nknown_usecases: [embeddings]\nprofile:\n system_prompt: Answer briefly.\n client_tools: drop\n",
"disabled-alias": "alias: target\ndisabled: true\n",
"broken": "alias: missing\nknown_usecases: [chat]\n",
"chain": "alias: alias\nknown_usecases: [chat]\n",
})
Expect(data).To(HaveLen(5))
Expect(data).To(HaveLen(3))
for _, entry := range data {
switch entry.ID {
case "target", "alias", "disabled-alias":
Expect(entry.Capabilities).To(ConsistOf("FLAG_CHAT", "FLAG_VISION"), "%s must inherit chat/vision", entry.ID)
case "broken", "chain":
Expect(entry.Capabilities).To(BeEmpty(), "invalid alias %s gained capabilities", entry.ID)
default:
Fail("unexpected ID: " + entry.ID)
}
if entry.ID != "target" {
Expect(entry.Backend).To(BeEmpty(), "alias %s inherited management metadata", entry.ID)
Expect(entry.Pinned).To(BeFalse(), entry.ID)
Expect(entry.MCP).To(BeFalse(), entry.ID)
Expect(entry.Capabilities).To(ConsistOf("FLAG_CHAT", "FLAG_VISION"), "%s must inherit chat/vision", entry.ID)
Expect(entry.Backend).To(Equal("llama-cpp"), entry.ID)
Expect(entry.AliasError).To(BeEmpty(), entry.ID)
if entry.ID == "target" {
Expect(entry.AliasOf).To(BeEmpty())
Expect(entry.Pinned).To(BeTrue())
continue
}
Expect(entry.AliasOf).To(Equal("target"), entry.ID)
Expect(entry.Pinned).To(BeFalse(), "alias %s must not inherit pinned", entry.ID)
Expect(entry.Disabled).To(Equal(entry.ID == "disabled-alias"), entry.ID)
}
})
It("reports an alias it cannot resolve instead of dropping it", func() {
data := capabilitiesOf(map[string]string{
"target": "backend: llama-cpp\nknown_usecases: [chat]\n",
"alias": "alias: target\n",
"broken": "alias: missing\nknown_usecases: [chat]\n",
"chain": "alias: alias\nknown_usecases: [chat]\n",
})
Expect(data).To(HaveLen(4))
for _, entry := range data {
switch entry.ID {
case "broken":
Expect(entry.AliasOf).To(Equal("missing"))
Expect(entry.AliasError).To(ContainSubstring("unknown model"))
case "chain":
Expect(entry.AliasOf).To(Equal("alias"))
Expect(entry.AliasError).To(ContainSubstring("chains are not allowed"))
default:
continue
}
Expect(entry.Capabilities).To(BeEmpty(), entry.ID)
Expect(entry.Backend).To(BeEmpty(), entry.ID)
}
})
})