diff --git a/core/http/routes/studio.go b/core/http/routes/studio.go index 3b1ea90462..400a8209b1 100644 --- a/core/http/routes/studio.go +++ b/core/http/routes/studio.go @@ -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 diff --git a/core/http/routes/studio_modelchecker_internal_test.go b/core/http/routes/studio_modelchecker_internal_test.go new file mode 100644 index 0000000000..b0f35829c4 --- /dev/null +++ b/core/http/routes/studio_modelchecker_internal_test.go @@ -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) + } + }) +}) diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go index 8be0abd20d..4983a6924b 100644 --- a/core/http/routes/ui_api.go +++ b/core/http/routes/ui_api.go @@ -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 diff --git a/core/http/routes/ui_api_capabilities_test.go b/core/http/routes/ui_api_capabilities_test.go index d5ec1c627b..bcc614083b 100644 --- a/core/http/routes/ui_api_capabilities_test.go +++ b/core/http/routes/ui_api_capabilities_test.go @@ -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) + } + }) })