mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-02 03:20:12 -04:00
* 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>
132 lines
4.8 KiB
Go
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)))
|
|
})
|
|
})
|