Skip to content

Commit b19afb1

Browse files
localai-botmudler
andauthored
fix(distributed): backend discovery hid GPU-only backends behind the controller's capability (#10947)
* fix(backends): list backends runnable on worker nodes in distributed mode GET /backends/available filtered the gallery against the system state of the host serving the request. In a distributed deployment that host is the controller, which typically has no GPU, while the GPUs live on worker nodes. Any meta backend whose capabilities map lacks a "default" (or "cpu") key was therefore dropped from the listing entirely — longcat-video, vllm-omni, ltx-video, parakeet, edgetam and qwentts were invisible in the UI even though installing them by name on a GPU worker worked fine. Workers now report their own meta-backend capability at registration and the controller persists it on the node row. The controller cannot derive it: OS-dependent capabilities (metal, darwin-x86, nvidia-l4t) and the CUDA runtime refinements are only observable on the worker. Nodes registered before this field existed fall back to a coarse capability derived from their GPU vendor and VRAM. Backend discovery then evaluates compatibility as the union over healthy backend nodes, so a backend runnable on any node is offered while one no node can run stays hidden. Each remote capability is evaluated through a capability-pinned system state, otherwise a forced capability on the controller image (LOCALAI_FORCE_META_BACKEND_CAPABILITY or /run/localai/capability) would silently override every worker's verdict. With no registered nodes the listing is byte-for-byte what it was, so single-node deployments are unaffected. Also fixes the same-root-cause misclassification in /api/operations, which used the capability-filtered listing to decide whether an operation was a backend or a model install. A GPU-only backend installing on a worker is still a backend operation on the controller, so that lookup is now unfiltered. Assisted-by: Claude:claude-opus-4-8 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(backends): union worker capabilities in backend discovery Implementation for the specs added in the previous commit, plus the two remaining discovery endpoints. Capability-filtered backend discovery evaluated compatibility against the system state of the host serving the request. In a distributed deployment that host is the controller, which typically has no GPU, while the GPUs live on worker nodes. Any meta backend whose capabilities map lacks a "default" (or "cpu") key was dropped entirely — longcat-video, vllm-omni, ltx-video, parakeet, edgetam and qwentts were invisible in the UI even though installing them by name on a GPU worker worked fine. Workers now report their own meta-backend capability at registration and the controller persists it on the node row. The controller cannot derive it: OS-dependent capabilities (metal, darwin-x86, nvidia-l4t) and the CUDA runtime refinements are only observable on the worker. Nodes registered before this field existed fall back to a coarse capability derived from their GPU vendor and VRAM. Discovery then evaluates compatibility as the union over healthy backend nodes, so a backend runnable on any node is offered while one no node can run stays hidden. Each remote capability is evaluated through a capability-pinned system state, otherwise a forced capability on the controller image (LOCALAI_FORCE_META_BACKEND_CAPABILITY or /run/localai/capability) would silently override every worker's verdict. With no registered nodes the listing is byte-for-byte what it was, so single-node deployments are unaffected. Four surfaces shared this root cause and are all routed through the same helper now: - GET /backends/available - GET /api/fine-tuning/backends - GET /api/quantization/backends - /api/operations backend-vs-model classification, which additionally had no reason to filter by capability at all: a GPU-only backend installing on a worker is still a backend operation on the controller, so that lookup is now unfiltered. Assisted-by: Claude:claude-opus-4-8 golangci-lint 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>
1 parent 963c637 commit b19afb1

20 files changed

Lines changed: 850 additions & 29 deletions
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
package gallery_test
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
7+
"github.com/mudler/LocalAI/core/config"
8+
. "github.com/mudler/LocalAI/core/gallery"
9+
"github.com/mudler/LocalAI/pkg/system"
10+
. "github.com/onsi/ginkgo/v2"
11+
. "github.com/onsi/gomega"
12+
"gopkg.in/yaml.v3"
13+
)
14+
15+
// These specs cover backend discovery in distributed mode, where the machine
16+
// answering GET /backends/available (the controller) is not the machine that
17+
// will run the backend (a worker). Filtering the listing against the
18+
// controller's own hardware hid every GPU-only meta backend from admins.
19+
var _ = Describe("AvailableBackendsForCapabilities", func() {
20+
var (
21+
tempDir string
22+
galleryPath string
23+
galleries []config.Gallery
24+
// controller stands in for a GPU-less frontend pod: no vendor, so its
25+
// reported capability is "default".
26+
controller *system.SystemState
27+
)
28+
29+
writeGalleryYAML := func(backends []GalleryBackend) {
30+
data, err := yaml.Marshal(backends)
31+
Expect(err).NotTo(HaveOccurred())
32+
Expect(os.WriteFile(galleryPath, data, 0644)).To(Succeed())
33+
}
34+
35+
names := func(backends GalleryElements[*GalleryBackend]) []string {
36+
out := []string{}
37+
for _, b := range backends {
38+
out = append(out, b.GetName())
39+
}
40+
return out
41+
}
42+
43+
// longcatVideo mirrors the real gallery entry that triggered this bug: a
44+
// meta backend enumerating only NVIDIA variants, with neither a "default"
45+
// nor a "cpu" key for Capability() to fall back to.
46+
longcatVideo := GalleryBackend{
47+
Metadata: Metadata{Name: "longcat-video"},
48+
CapabilitiesMap: map[string]string{
49+
"nvidia": "longcat-video-nvidia",
50+
"nvidia-cuda-12": "longcat-video-cuda-12",
51+
"nvidia-cuda-13": "longcat-video-cuda-13",
52+
"nvidia-l4t-cuda-13": "longcat-video-l4t-cuda-13",
53+
},
54+
}
55+
56+
// cpuMeta is compatible with every host and must keep showing up in all
57+
// scenarios, proving the union widens the listing without replacing it.
58+
cpuMeta := GalleryBackend{
59+
Metadata: Metadata{Name: "whisper"},
60+
CapabilitiesMap: map[string]string{"default": "whisper-cpu", "nvidia": "whisper-cuda"},
61+
}
62+
63+
BeforeEach(func() {
64+
var err error
65+
tempDir, err = os.MkdirTemp("", "node-capability-test-*")
66+
Expect(err).NotTo(HaveOccurred())
67+
DeferCleanup(func() {
68+
Expect(os.RemoveAll(tempDir)).To(Succeed())
69+
})
70+
71+
galleryPath = filepath.Join(tempDir, "gallery.yaml")
72+
writeGalleryYAML([]GalleryBackend{longcatVideo, cpuMeta})
73+
74+
galleries = []config.Gallery{{Name: "test-gallery", URL: "file://" + galleryPath}}
75+
controller = system.NewCapabilityState("default", system.WithBackendPath(tempDir))
76+
})
77+
78+
It("hides a GPU-only meta when no node capabilities are supplied", func() {
79+
backends, err := AvailableBackendsForCapabilities(galleries, controller, nil)
80+
Expect(err).NotTo(HaveOccurred())
81+
Expect(names(backends)).To(ContainElement("whisper"))
82+
Expect(names(backends)).NotTo(ContainElement("longcat-video"))
83+
})
84+
85+
It("lists a GPU-only meta runnable on a registered worker node", func() {
86+
backends, err := AvailableBackendsForCapabilities(galleries, controller, []string{"nvidia-l4t-cuda-13"})
87+
Expect(err).NotTo(HaveOccurred())
88+
Expect(names(backends)).To(ContainElement("longcat-video"))
89+
Expect(names(backends)).To(ContainElement("whisper"))
90+
})
91+
92+
It("unions across heterogeneous nodes rather than intersecting them", func() {
93+
writeGalleryYAML([]GalleryBackend{
94+
longcatVideo,
95+
{
96+
Metadata: Metadata{Name: "amd-only"},
97+
CapabilitiesMap: map[string]string{"amd": "amd-only-rocm"},
98+
},
99+
})
100+
101+
backends, err := AvailableBackendsForCapabilities(galleries, controller, []string{"nvidia", "amd"})
102+
Expect(err).NotTo(HaveOccurred())
103+
Expect(names(backends)).To(ContainElements("longcat-video", "amd-only"))
104+
})
105+
106+
It("still excludes a meta no node can satisfy", func() {
107+
backends, err := AvailableBackendsForCapabilities(galleries, controller, []string{"amd"})
108+
Expect(err).NotTo(HaveOccurred())
109+
Expect(names(backends)).NotTo(ContainElement("longcat-video"))
110+
})
111+
112+
It("filters concrete backends by node capability too", func() {
113+
writeGalleryYAML([]GalleryBackend{
114+
{Metadata: Metadata{Name: "some-backend-cuda"}, URI: "quay.io/test/cuda"},
115+
{Metadata: Metadata{Name: "some-backend-rocm"}, URI: "quay.io/test/rocm"},
116+
})
117+
118+
backends, err := AvailableBackendsForCapabilities(galleries, controller, []string{"nvidia"})
119+
Expect(err).NotTo(HaveOccurred())
120+
Expect(names(backends)).To(ContainElement("some-backend-cuda"))
121+
Expect(names(backends)).NotTo(ContainElement("some-backend-rocm"))
122+
})
123+
124+
It("returns exactly the single-node listing when the node list is empty", func() {
125+
withNodes, err := AvailableBackendsForCapabilities(galleries, controller, []string{})
126+
Expect(err).NotTo(HaveOccurred())
127+
baseline, err := AvailableBackends(galleries, controller)
128+
Expect(err).NotTo(HaveOccurred())
129+
Expect(names(withNodes)).To(Equal(names(baseline)))
130+
})
131+
})

core/gallery/gallery.go

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -395,16 +395,57 @@ func triggerGalleryRefresh(galleries []config.Gallery, systemState *system.Syste
395395

396396
// List available backends
397397
func AvailableBackends(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
398-
return availableBackendsWithFilter(galleries, systemState, true)
398+
return availableBackendsWithFilter(galleries, systemState, func(backend *GalleryBackend) bool {
399+
return backend.IsCompatibleWith(systemState)
400+
})
399401
}
400402

401403
// AvailableBackendsUnfiltered returns all available backends without filtering by system capability.
402404
func AvailableBackendsUnfiltered(galleries []config.Gallery, systemState *system.SystemState) (GalleryElements[*GalleryBackend], error) {
403-
return availableBackendsWithFilter(galleries, systemState, false)
405+
return availableBackendsWithFilter(galleries, systemState, nil)
404406
}
405407

406-
// availableBackendsWithFilter is a helper function that lists available backends with optional filtering.
407-
func availableBackendsWithFilter(galleries []config.Gallery, systemState *system.SystemState, filterByCapability bool) (GalleryElements[*GalleryBackend], error) {
408+
// AvailableBackendsForCapabilities lists backends runnable on the local system
409+
// OR on any remote host reporting one of the supplied capabilities.
410+
//
411+
// In a distributed deployment the host serving this listing (the controller)
412+
// is usually a GPU-less pod while the GPUs live on worker nodes. Filtering
413+
// only against the controller hid every GPU-only meta backend from admins even
414+
// though installing it by name on a worker worked fine, so compatibility is
415+
// evaluated as a union over the cluster. An empty capabilities slice reproduces
416+
// AvailableBackends exactly, keeping single-node behavior untouched.
417+
func AvailableBackendsForCapabilities(galleries []config.Gallery, systemState *system.SystemState, capabilities []string) (GalleryElements[*GalleryBackend], error) {
418+
if len(capabilities) == 0 {
419+
return AvailableBackends(galleries, systemState)
420+
}
421+
422+
// Each remote capability is evaluated through a state pinned to that exact
423+
// capability, so the controller's own detection (and any forced capability
424+
// on the controller image) cannot leak into the worker's verdict. Backend
425+
// paths still come from the controller's state because that is where the
426+
// gallery metadata is read from.
427+
nodeStates := make([]*system.SystemState, 0, len(capabilities))
428+
for _, capability := range capabilities {
429+
nodeStates = append(nodeStates, system.NewCapabilityState(capability,
430+
system.WithBackendPath(systemState.Backend.BackendsPath)))
431+
}
432+
433+
return availableBackendsWithFilter(galleries, systemState, func(backend *GalleryBackend) bool {
434+
if backend.IsCompatibleWith(systemState) {
435+
return true
436+
}
437+
for _, nodeState := range nodeStates {
438+
if backend.IsCompatibleWith(nodeState) {
439+
return true
440+
}
441+
}
442+
return false
443+
})
444+
}
445+
446+
// availableBackendsWithFilter lists available backends, keeping only those
447+
// accepted by compatible. A nil compatible keeps everything.
448+
func availableBackendsWithFilter(galleries []config.Gallery, systemState *system.SystemState, compatible func(*GalleryBackend) bool) (GalleryElements[*GalleryBackend], error) {
408449
var backends []*GalleryBackend
409450

410451
systemBackends, err := ListSystemBackends(systemState)
@@ -421,15 +462,15 @@ func availableBackendsWithFilter(galleries []config.Gallery, systemState *system
421462
return nil, err
422463
}
423464

424-
// Filter backends by system capability if requested
425-
if filterByCapability {
426-
for _, backend := range galleryBackends {
427-
if backend.IsCompatibleWith(systemState) {
428-
backends = append(backends, backend)
429-
}
430-
}
431-
} else {
465+
if compatible == nil {
432466
backends = append(backends, galleryBackends...)
467+
continue
468+
}
469+
470+
for _, backend := range galleryBackends {
471+
if compatible(backend) {
472+
backends = append(backends, backend)
473+
}
433474
}
434475
}
435476

core/http/app.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ func API(application *application.Application) (*echo.Echo, error) {
443443
ftNats,
444444
ftStore,
445445
)
446-
routes.RegisterFineTuningRoutes(e, ftService, application.ApplicationConfig(), fineTuningMw)
446+
routes.RegisterFineTuningRoutes(e, ftService, application.ApplicationConfig(), application, fineTuningMw)
447447

448448
// Quantization routes
449449
quantizationMw := auth.RequireFeature(application.AuthDB(), auth.FeatureQuantization)
@@ -465,7 +465,7 @@ func API(application *application.Application) (*echo.Echo, error) {
465465
quantNats,
466466
quantStore,
467467
)
468-
routes.RegisterQuantizationRoutes(e, qService, application.ApplicationConfig(), quantizationMw)
468+
routes.RegisterQuantizationRoutes(e, qService, application.ApplicationConfig(), application, quantizationMw)
469469

470470
// Node management routes (distributed mode)
471471
distCfg := application.ApplicationConfig().Distributed

core/http/endpoints/localai/backend.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package localai
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"sort"
@@ -323,14 +324,41 @@ func (mgs *BackendEndpointService) UpgradeBackendEndpoint() echo.HandlerFunc {
323324
}
324325
}
325326

327+
// ClusterCapabilityProvider reports the meta-backend capabilities available
328+
// somewhere in the cluster. It is nil in single-node deployments, where the
329+
// local system state is the only thing worth filtering against.
330+
type ClusterCapabilityProvider func(ctx context.Context) ([]string, error)
331+
332+
// resolveClusterCapabilities reads the capabilities present in the cluster,
333+
// degrading to the local-only listing on error.
334+
//
335+
// Every capability-filtered discovery endpoint shares this: on a distributed
336+
// controller the GPUs live on the workers, so filtering against the local
337+
// (usually GPU-less) host hides GPU-only backends the cluster can actually
338+
// run. A registry hiccup must never blank the catalog, so a failure falls back
339+
// to the pre-existing local-only behavior rather than erroring the request.
340+
func resolveClusterCapabilities(ctx context.Context, provider ClusterCapabilityProvider) []string {
341+
if provider == nil {
342+
return nil
343+
}
344+
capabilities, err := provider(ctx)
345+
if err != nil {
346+
xlog.Warn("Could not read cluster capabilities, listing backends for the local system only", "error", err)
347+
return nil
348+
}
349+
return capabilities
350+
}
351+
326352
// ListAvailableBackendsEndpoint list the available backends in the galleries configured in LocalAI
327353
// @Summary List all available Backends
328354
// @Tags backends
329355
// @Success 200 {object} []gallery.GalleryBackend "Response"
330356
// @Router /backends/available [get]
331-
func (mgs *BackendEndpointService) ListAvailableBackendsEndpoint(systemState *system.SystemState) echo.HandlerFunc {
357+
func (mgs *BackendEndpointService) ListAvailableBackendsEndpoint(systemState *system.SystemState, clusterCapabilities ClusterCapabilityProvider) echo.HandlerFunc {
332358
return func(c echo.Context) error {
333-
backends, err := gallery.AvailableBackends(mgs.galleries, systemState)
359+
capabilities := resolveClusterCapabilities(c.Request().Context(), clusterCapabilities)
360+
361+
backends, err := gallery.AvailableBackendsForCapabilities(mgs.galleries, systemState, capabilities)
334362
if err != nil {
335363
return err
336364
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package localai_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"errors"
7+
"net/http"
8+
"net/http/httptest"
9+
"os"
10+
"path/filepath"
11+
12+
"github.com/labstack/echo/v4"
13+
"github.com/mudler/LocalAI/core/config"
14+
"github.com/mudler/LocalAI/core/gallery"
15+
. "github.com/mudler/LocalAI/core/http/endpoints/localai"
16+
"github.com/mudler/LocalAI/pkg/system"
17+
. "github.com/onsi/ginkgo/v2"
18+
. "github.com/onsi/gomega"
19+
"gopkg.in/yaml.v3"
20+
)
21+
22+
// GET /backends/available on a distributed controller must advertise what the
23+
// cluster can run, not what the controller itself can run.
24+
var _ = Describe("ListAvailableBackendsEndpoint cluster capabilities", func() {
25+
var (
26+
app *echo.Echo
27+
systemState *system.SystemState
28+
tmpDir string
29+
galleries []config.Gallery
30+
)
31+
32+
BeforeEach(func() {
33+
app = echo.New()
34+
35+
var err error
36+
tmpDir, err = os.MkdirTemp("", "backends-available-cluster-*")
37+
Expect(err).NotTo(HaveOccurred())
38+
DeferCleanup(func() {
39+
Expect(os.RemoveAll(tmpDir)).To(Succeed())
40+
})
41+
42+
galleryPath := filepath.Join(tmpDir, "gallery.yaml")
43+
data, err := yaml.Marshal([]gallery.GalleryBackend{
44+
{
45+
Metadata: gallery.Metadata{Name: "longcat-video"},
46+
CapabilitiesMap: map[string]string{
47+
"nvidia": "longcat-video-nvidia",
48+
"nvidia-cuda-13": "longcat-video-cuda-13",
49+
"nvidia-l4t-cuda-13": "longcat-video-l4t-cuda-13",
50+
},
51+
},
52+
{
53+
Metadata: gallery.Metadata{Name: "whisper"},
54+
CapabilitiesMap: map[string]string{"default": "whisper-cpu"},
55+
},
56+
})
57+
Expect(err).NotTo(HaveOccurred())
58+
Expect(os.WriteFile(galleryPath, data, 0o644)).To(Succeed())
59+
60+
galleries = []config.Gallery{{Name: "test-gallery", URL: "file://" + galleryPath}}
61+
62+
// A GPU-less controller pod: capability "default".
63+
systemState = system.NewCapabilityState("default",
64+
system.WithBackendPath(tmpDir), system.WithBackendSystemPath(tmpDir))
65+
})
66+
67+
listNames := func(provider ClusterCapabilityProvider) []string {
68+
svc := CreateBackendEndpointService(galleries, systemState, nil, nil)
69+
app.GET("/backends/available", svc.ListAvailableBackendsEndpoint(systemState, provider))
70+
71+
req := httptest.NewRequest(http.MethodGet, "/backends/available", nil)
72+
rec := httptest.NewRecorder()
73+
app.ServeHTTP(rec, req)
74+
Expect(rec.Code).To(Equal(http.StatusOK))
75+
76+
var backends []gallery.GalleryBackend
77+
Expect(json.Unmarshal(rec.Body.Bytes(), &backends)).To(Succeed())
78+
79+
names := []string{}
80+
for _, b := range backends {
81+
names = append(names, b.Name)
82+
}
83+
return names
84+
}
85+
86+
It("hides GPU-only backends with no provider (single-node)", func() {
87+
names := listNames(nil)
88+
Expect(names).To(ContainElement("whisper"))
89+
Expect(names).NotTo(ContainElement("longcat-video"))
90+
})
91+
92+
It("lists GPU-only backends a worker node can run", func() {
93+
provider := func(ctx context.Context) ([]string, error) {
94+
return []string{"nvidia-l4t-cuda-13"}, nil
95+
}
96+
Expect(listNames(provider)).To(ContainElements("whisper", "longcat-video"))
97+
})
98+
99+
It("falls back to the local listing when the registry errors", func() {
100+
// A registry hiccup must degrade to the old behavior, never blank the
101+
// backend catalog with a 500.
102+
provider := func(ctx context.Context) ([]string, error) {
103+
return nil, errors.New("registry unavailable")
104+
}
105+
names := listNames(provider)
106+
Expect(names).To(ContainElement("whisper"))
107+
Expect(names).NotTo(ContainElement("longcat-video"))
108+
})
109+
})

0 commit comments

Comments
 (0)