Files
LocalAI/core/gallery/backends_nodecapability_test.go
mudler's LocalAI [bot] b19afb192a 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>
2026-07-19 07:53:46 +00:00

132 lines
4.8 KiB
Go

package gallery_test
import (
"os"
"path/filepath"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
// These specs cover backend discovery in distributed mode, where the machine
// answering GET /backends/available (the controller) is not the machine that
// will run the backend (a worker). Filtering the listing against the
// controller's own hardware hid every GPU-only meta backend from admins.
var _ = Describe("AvailableBackendsForCapabilities", func() {
var (
tempDir string
galleryPath string
galleries []config.Gallery
// controller stands in for a GPU-less frontend pod: no vendor, so its
// reported capability is "default".
controller *system.SystemState
)
writeGalleryYAML := func(backends []GalleryBackend) {
data, err := yaml.Marshal(backends)
Expect(err).NotTo(HaveOccurred())
Expect(os.WriteFile(galleryPath, data, 0644)).To(Succeed())
}
names := func(backends GalleryElements[*GalleryBackend]) []string {
out := []string{}
for _, b := range backends {
out = append(out, b.GetName())
}
return out
}
// longcatVideo mirrors the real gallery entry that triggered this bug: a
// meta backend enumerating only NVIDIA variants, with neither a "default"
// nor a "cpu" key for Capability() to fall back to.
longcatVideo := GalleryBackend{
Metadata: Metadata{Name: "longcat-video"},
CapabilitiesMap: map[string]string{
"nvidia": "longcat-video-nvidia",
"nvidia-cuda-12": "longcat-video-cuda-12",
"nvidia-cuda-13": "longcat-video-cuda-13",
"nvidia-l4t-cuda-13": "longcat-video-l4t-cuda-13",
},
}
// cpuMeta is compatible with every host and must keep showing up in all
// scenarios, proving the union widens the listing without replacing it.
cpuMeta := GalleryBackend{
Metadata: Metadata{Name: "whisper"},
CapabilitiesMap: map[string]string{"default": "whisper-cpu", "nvidia": "whisper-cuda"},
}
BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "node-capability-test-*")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
Expect(os.RemoveAll(tempDir)).To(Succeed())
})
galleryPath = filepath.Join(tempDir, "gallery.yaml")
writeGalleryYAML([]GalleryBackend{longcatVideo, cpuMeta})
galleries = []config.Gallery{{Name: "test-gallery", URL: "file://" + galleryPath}}
controller = system.NewCapabilityState("default", system.WithBackendPath(tempDir))
})
It("hides a GPU-only meta when no node capabilities are supplied", func() {
backends, err := AvailableBackendsForCapabilities(galleries, controller, nil)
Expect(err).NotTo(HaveOccurred())
Expect(names(backends)).To(ContainElement("whisper"))
Expect(names(backends)).NotTo(ContainElement("longcat-video"))
})
It("lists a GPU-only meta runnable on a registered worker node", func() {
backends, err := AvailableBackendsForCapabilities(galleries, controller, []string{"nvidia-l4t-cuda-13"})
Expect(err).NotTo(HaveOccurred())
Expect(names(backends)).To(ContainElement("longcat-video"))
Expect(names(backends)).To(ContainElement("whisper"))
})
It("unions across heterogeneous nodes rather than intersecting them", func() {
writeGalleryYAML([]GalleryBackend{
longcatVideo,
{
Metadata: Metadata{Name: "amd-only"},
CapabilitiesMap: map[string]string{"amd": "amd-only-rocm"},
},
})
backends, err := AvailableBackendsForCapabilities(galleries, controller, []string{"nvidia", "amd"})
Expect(err).NotTo(HaveOccurred())
Expect(names(backends)).To(ContainElements("longcat-video", "amd-only"))
})
It("still excludes a meta no node can satisfy", func() {
backends, err := AvailableBackendsForCapabilities(galleries, controller, []string{"amd"})
Expect(err).NotTo(HaveOccurred())
Expect(names(backends)).NotTo(ContainElement("longcat-video"))
})
It("filters concrete backends by node capability too", func() {
writeGalleryYAML([]GalleryBackend{
{Metadata: Metadata{Name: "some-backend-cuda"}, URI: "quay.io/test/cuda"},
{Metadata: Metadata{Name: "some-backend-rocm"}, URI: "quay.io/test/rocm"},
})
backends, err := AvailableBackendsForCapabilities(galleries, controller, []string{"nvidia"})
Expect(err).NotTo(HaveOccurred())
Expect(names(backends)).To(ContainElement("some-backend-cuda"))
Expect(names(backends)).NotTo(ContainElement("some-backend-rocm"))
})
It("returns exactly the single-node listing when the node list is empty", func() {
withNodes, err := AvailableBackendsForCapabilities(galleries, controller, []string{})
Expect(err).NotTo(HaveOccurred())
baseline, err := AvailableBackends(galleries, controller)
Expect(err).NotTo(HaveOccurred())
Expect(names(withNodes)).To(Equal(names(baseline)))
})
})