Files
LocalAI/core/services/galleryop/managers_local.go
Ettore Di Giacinto 2da1a4d230 feat(distributed): per-node backend installation from the gallery
In distributed mode the Backends gallery used to fan every install out to
every worker — fine for auto-resolving (meta) backends like llama-cpp where
each node picks its own variant, but wrong for hardware-specific builds
like cpu-llama-cpp that would silently land on every GPU node.

Adds a node-targeted install path through the existing
POST /api/nodes/:id/backends/install plumbing, with two entry points:

- Backends gallery row gets a split-button in distributed mode. Auto-
  resolving keeps "Install on all nodes" as the primary; chevron menu
  opens the picker. Hardware-specific routes the primary directly to the
  picker — no fan-out path on the row.
- Nodes-page drawer gets a "+ Add backend" button that navigates to
  /app/backends?target=<node-id>; the gallery scopes itself to that node
  (banner, single per-row install button, Reinstall/Remove for already-
  installed). One gallery, two scopes — no second UI to maintain.

The picker (new NodeInstallPicker) shows a 3-state suitability column
(Compatible / Override / Installed), an auto-expanding variant override
disclosure that fires when selected nodes have no working GPU, parallel
per-node installs with inline status and Retry-failed-nodes, and a
mismatch confirm that names the consequence on the button itself.

A 409 fan-out guard on /api/backends/apply protects CLI/Terraform/script
users from the same footgun: hardware-specific installs in distributed
mode now return code "concrete_backend_requires_target" with a human-
readable error and a meta_alternative pointer.

The gallery list payload now surfaces capabilities, metaBackendFor and
per-row nodes (NodeBackendRef) so the picker and the new Nodes column
have everything they need without re-walking the gallery client-side.

GODEBUG=netdns=go is set on the compose services because the cgo DNS
resolver follows the container's nsswitch.conf to host systemd-resolved
(127.0.0.53), unreachable from inside the container; the pure-Go
resolver reads /etc/resolv.conf directly and uses Docker's embedded DNS.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-7[1m] [Edit] [Bash] [Read] [Write]
2026-04-26 22:05:18 +00:00

113 lines
4.4 KiB
Go

package galleryop
import (
"context"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/xlog"
)
// LocalModelManager handles model install/delete on the local instance.
type LocalModelManager struct {
systemState *system.SystemState
modelLoader *model.ModelLoader
enforcePredownloadScans bool
automaticallyInstallBackend bool
}
// NewLocalModelManager creates a LocalModelManager from the application config.
func NewLocalModelManager(appConfig *config.ApplicationConfig, ml *model.ModelLoader) *LocalModelManager {
return &LocalModelManager{
systemState: appConfig.SystemState,
modelLoader: ml,
enforcePredownloadScans: appConfig.EnforcePredownloadScans,
automaticallyInstallBackend: appConfig.AutoloadBackendGalleries,
}
}
// SetAutoInstallBackend controls whether backend binaries are automatically
// installed when a model is installed. In distributed mode the frontend node
// disables this because backends only run on workers.
func (m *LocalModelManager) SetAutoInstallBackend(v bool) {
m.automaticallyInstallBackend = v
}
func (m *LocalModelManager) DeleteModel(name string) error {
if err := m.modelLoader.ShutdownModel(name); err != nil {
xlog.Warn("Failed to unload model during deletion", "model", name, "error", err)
}
return gallery.DeleteModelFromSystem(m.systemState, name)
}
func (m *LocalModelManager) InstallModel(ctx context.Context, op *ManagementOp[gallery.GalleryModel, gallery.ModelConfig], progressCb ProgressCallback) error {
switch {
case op.GalleryElement != nil:
installedModel, err := gallery.InstallModel(ctx, m.systemState, op.GalleryElement.Name,
op.GalleryElement, op.Req.Overrides, progressCb, m.enforcePredownloadScans)
if err != nil {
return err
}
if m.automaticallyInstallBackend && installedModel.Backend != "" {
xlog.Debug("Installing backend", "backend", installedModel.Backend)
return gallery.InstallBackendFromGallery(ctx, op.BackendGalleries, m.systemState,
m.modelLoader, installedModel.Backend, progressCb, false)
}
return nil
case op.GalleryElementName != "":
return gallery.InstallModelFromGallery(ctx, op.Galleries, op.BackendGalleries,
m.systemState, m.modelLoader, op.GalleryElementName, op.Req, progressCb,
m.enforcePredownloadScans, m.automaticallyInstallBackend)
default:
return installModelFromRemoteConfig(ctx, m.systemState, m.modelLoader, op.Req,
progressCb, m.enforcePredownloadScans, m.automaticallyInstallBackend, op.BackendGalleries)
}
}
// LocalBackendManager handles backend install/delete on the local instance.
type LocalBackendManager struct {
systemState *system.SystemState
modelLoader *model.ModelLoader
backendGalleries []config.Gallery
}
// NewLocalBackendManager creates a LocalBackendManager from the application config.
func NewLocalBackendManager(appConfig *config.ApplicationConfig, ml *model.ModelLoader) *LocalBackendManager {
return &LocalBackendManager{
systemState: appConfig.SystemState,
modelLoader: ml,
backendGalleries: appConfig.BackendGalleries,
}
}
func (b *LocalBackendManager) DeleteBackend(name string) error {
err := gallery.DeleteBackendFromSystem(b.systemState, name)
b.modelLoader.DeleteExternalBackend(name)
return err
}
func (b *LocalBackendManager) ListBackends() (gallery.SystemBackends, error) {
return gallery.ListSystemBackends(b.systemState)
}
func (b *LocalBackendManager) UpgradeBackend(ctx context.Context, name string, progressCb ProgressCallback) error {
return gallery.UpgradeBackend(ctx, b.systemState, b.modelLoader, b.backendGalleries, name, progressCb)
}
func (b *LocalBackendManager) CheckUpgrades(ctx context.Context) (map[string]gallery.UpgradeInfo, error) {
return gallery.CheckBackendUpgrades(ctx, b.backendGalleries, b.systemState)
}
func (b *LocalBackendManager) InstallBackend(ctx context.Context, op *ManagementOp[gallery.GalleryBackend, any], progressCb ProgressCallback) error {
if op.ExternalURI != "" {
return InstallExternalBackend(ctx, b.backendGalleries, b.systemState, b.modelLoader,
progressCb, op.ExternalURI, op.ExternalName, op.ExternalAlias)
}
return gallery.InstallBackendFromGallery(ctx, b.backendGalleries, b.systemState,
b.modelLoader, op.GalleryElementName, progressCb, true)
}
func (b *LocalBackendManager) IsDistributed() bool { return false }