Skip to content

Commit 9d82c37

Browse files
localai-botmudler
andauthored
fix(distributed): backend discovery hid worker-installed backends behind the controller's filesystem (#10967)
fix(distributed): backend discovery hid worker-installed backends Backend discovery endpoints filter on installed-state, which on a distributed controller derives from the controller's own filesystem. A backend lives on the worker node that runs it, so every backend an admin installed on a GPU worker read as "not installed" and vanished from the listing. #10947 fixed the sibling capability filter on the same endpoints, so a fine-tuning-capable GPU worker now made the backend listable while the installed-state filter still dropped it: the dropdown stayed empty. The controller cannot derive this locally, but it already aggregates the per-node view that GET /backends renders, so discovery reuses the active BackendManager rather than growing a second path. Three surfaces shared the root cause and route through the same helper now: - GET /backends/available (Installed is now cluster-wide) - GET /api/fine-tuning/backends - GET /api/quantization/backends The response stays a boolean rather than an installed-on-N-of-M count: per-node install state is already served by GET /backends nodes[], and per-node control by POST /api/nodes/:id/backends/install, so a summary is all these dropdowns need. A nil provider (single-node) leaves the local filesystem as the only source and reproduces today's listing exactly, and a registry error degrades to that same listing instead of blanking the catalog. Assisted-by: Claude:claude-opus-4-8 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent f735cb2 commit 9d82c37

10 files changed

Lines changed: 267 additions & 13 deletions

core/http/endpoints/localai/backend.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,19 +349,65 @@ func resolveClusterCapabilities(ctx context.Context, provider ClusterCapabilityP
349349
return capabilities
350350
}
351351

352+
// ClusterInstalledProvider reports the backends installed somewhere in the
353+
// cluster. It is nil in single-node deployments, where the local filesystem is
354+
// the only install state that exists.
355+
type ClusterInstalledProvider func(ctx context.Context) ([]string, error)
356+
357+
// resolveClusterInstalled reads the backends installed across the cluster,
358+
// degrading to the local-only view on error.
359+
//
360+
// Every discovery endpoint that filters on installed-state shares this: a
361+
// backend lives on the worker node that runs it, so the controller's own
362+
// filesystem reports it missing and the endpoint hides it. A nil set leaves
363+
// the local filesystem as the only source, so single-node listings are
364+
// untouched, and a registry hiccup degrades to that same listing rather than
365+
// erroring the request.
366+
func resolveClusterInstalled(ctx context.Context, provider ClusterInstalledProvider) map[string]struct{} {
367+
if provider == nil {
368+
return nil
369+
}
370+
names, err := provider(ctx)
371+
if err != nil {
372+
xlog.Warn("Could not read cluster backend install state, reporting the local system only", "error", err)
373+
return nil
374+
}
375+
installed := make(map[string]struct{}, len(names))
376+
for _, name := range names {
377+
installed[name] = struct{}{}
378+
}
379+
return installed
380+
}
381+
382+
// installedInCluster reports whether a backend is installed on the host serving
383+
// the listing or on any node of the cluster.
384+
func installedInCluster(backend *gallery.GalleryBackend, clusterInstalled map[string]struct{}) bool {
385+
if backend.Installed {
386+
return true
387+
}
388+
_, ok := clusterInstalled[backend.Name]
389+
return ok
390+
}
391+
352392
// ListAvailableBackendsEndpoint list the available backends in the galleries configured in LocalAI
353393
// @Summary List all available Backends
354394
// @Tags backends
355395
// @Success 200 {object} []gallery.GalleryBackend "Response"
356396
// @Router /backends/available [get]
357-
func (mgs *BackendEndpointService) ListAvailableBackendsEndpoint(systemState *system.SystemState, clusterCapabilities ClusterCapabilityProvider) echo.HandlerFunc {
397+
func (mgs *BackendEndpointService) ListAvailableBackendsEndpoint(systemState *system.SystemState, clusterCapabilities ClusterCapabilityProvider, clusterInstalled ClusterInstalledProvider) echo.HandlerFunc {
358398
return func(c echo.Context) error {
359399
capabilities := resolveClusterCapabilities(c.Request().Context(), clusterCapabilities)
360400

361401
backends, err := gallery.AvailableBackendsForCapabilities(mgs.galleries, systemState, capabilities)
362402
if err != nil {
363403
return err
364404
}
405+
406+
installed := resolveClusterInstalled(c.Request().Context(), clusterInstalled)
407+
for _, b := range backends {
408+
b.SetInstalled(installedInCluster(b, installed))
409+
}
410+
365411
return c.JSON(200, backends)
366412
}
367413
}

core/http/endpoints/localai/backend_available_cluster_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ var _ = Describe("ListAvailableBackendsEndpoint cluster capabilities", func() {
6666

6767
listNames := func(provider ClusterCapabilityProvider) []string {
6868
svc := CreateBackendEndpointService(galleries, systemState, nil, nil)
69-
app.GET("/backends/available", svc.ListAvailableBackendsEndpoint(systemState, provider))
69+
app.GET("/backends/available", svc.ListAvailableBackendsEndpoint(systemState, provider, nil))
7070

7171
req := httptest.NewRequest(http.MethodGet, "/backends/available", nil)
7272
rec := httptest.NewRecorder()

core/http/endpoints/localai/discovery_cluster_capabilities_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,26 +100,26 @@ var _ = Describe("Tagged backend discovery with cluster capabilities", func() {
100100

101101
Describe("fine-tune backends", func() {
102102
It("hides GPU-only backends with no cluster provider", func() {
103-
names := listNames("/backends", ListFineTuneBackendsEndpoint(appCfg, nil))
103+
names := listNames("/backends", ListFineTuneBackendsEndpoint(appCfg, nil, nil))
104104
Expect(names).To(ContainElement("cpu-trainer"))
105105
Expect(names).NotTo(ContainElement("gpu-trainer"))
106106
})
107107

108108
It("lists GPU-only backends a worker node can run", func() {
109-
names := listNames("/backends", ListFineTuneBackendsEndpoint(appCfg, nvidiaWorker))
109+
names := listNames("/backends", ListFineTuneBackendsEndpoint(appCfg, nvidiaWorker, nil))
110110
Expect(names).To(ContainElements("cpu-trainer", "gpu-trainer"))
111111
})
112112
})
113113

114114
Describe("quantization backends", func() {
115115
It("hides GPU-only backends with no cluster provider", func() {
116-
names := listNames("/backends", ListQuantizationBackendsEndpoint(appCfg, nil))
116+
names := listNames("/backends", ListQuantizationBackendsEndpoint(appCfg, nil, nil))
117117
Expect(names).To(ContainElement("cpu-trainer"))
118118
Expect(names).NotTo(ContainElement("gpu-trainer"))
119119
})
120120

121121
It("lists GPU-only backends a worker node can run", func() {
122-
names := listNames("/backends", ListQuantizationBackendsEndpoint(appCfg, nvidiaWorker))
122+
names := listNames("/backends", ListQuantizationBackendsEndpoint(appCfg, nvidiaWorker, nil))
123123
Expect(names).To(ContainElements("cpu-trainer", "gpu-trainer"))
124124
})
125125
})
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
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+
// Installed-state on a distributed controller is derived from the controller's
23+
// own filesystem, where a backend installed on a GPU worker does not exist.
24+
// The capability fix (#10947) made those backends *listable*; every surface
25+
// that filters on installed-state still dropped them, so an admin with a
26+
// fine-tuning-capable GPU worker got an empty dropdown.
27+
var _ = Describe("Backend discovery with cluster install state", func() {
28+
var (
29+
appCfg *config.ApplicationConfig
30+
systemState *system.SystemState
31+
galleries []config.Gallery
32+
tmpDir string
33+
)
34+
35+
BeforeEach(func() {
36+
var err error
37+
tmpDir, err = os.MkdirTemp("", "discovery-cluster-installed-*")
38+
Expect(err).NotTo(HaveOccurred())
39+
DeferCleanup(func() {
40+
Expect(os.RemoveAll(tmpDir)).To(Succeed())
41+
})
42+
43+
// cpu-trainer sits on the controller's disk; gpu-trainer exists only on
44+
// a worker, so nothing on this filesystem can prove it is installed.
45+
writeFakeSystemBackend(tmpDir, "cpu-trainer")
46+
47+
galleryPath := filepath.Join(tmpDir, "gallery.yaml")
48+
data, err := yaml.Marshal([]gallery.GalleryBackend{
49+
{
50+
Metadata: gallery.Metadata{
51+
Name: "gpu-trainer",
52+
Tags: []string{"fine-tuning", "quantization"},
53+
},
54+
CapabilitiesMap: map[string]string{"nvidia-cuda-13": "gpu-trainer-cuda-13"},
55+
},
56+
{
57+
Metadata: gallery.Metadata{
58+
Name: "cpu-trainer",
59+
Tags: []string{"fine-tuning", "quantization"},
60+
},
61+
CapabilitiesMap: map[string]string{"default": "cpu-trainer-cpu"},
62+
},
63+
})
64+
Expect(err).NotTo(HaveOccurred())
65+
Expect(os.WriteFile(galleryPath, data, 0o644)).To(Succeed())
66+
67+
galleries = []config.Gallery{{Name: "test-gallery", URL: "file://" + galleryPath}}
68+
// A GPU-less controller pod: capability "default".
69+
systemState = system.NewCapabilityState("default",
70+
system.WithBackendPath(tmpDir), system.WithBackendSystemPath(tmpDir))
71+
appCfg = &config.ApplicationConfig{
72+
BackendGalleries: galleries,
73+
SystemState: systemState,
74+
}
75+
})
76+
77+
nvidiaWorker := func(ctx context.Context) ([]string, error) {
78+
return []string{"nvidia-cuda-13"}, nil
79+
}
80+
installedOnWorker := func(ctx context.Context) ([]string, error) {
81+
return []string{"gpu-trainer"}, nil
82+
}
83+
registryDown := func(ctx context.Context) ([]string, error) {
84+
return nil, errors.New("registry unavailable")
85+
}
86+
87+
// listNames drives handler over its route and returns the backend names.
88+
listNames := func(path string, handler echo.HandlerFunc) []string {
89+
e := echo.New()
90+
e.GET(path, handler)
91+
92+
req := httptest.NewRequest(http.MethodGet, path, nil)
93+
rec := httptest.NewRecorder()
94+
e.ServeHTTP(rec, req)
95+
Expect(rec.Code).To(Equal(http.StatusOK))
96+
97+
var backends []struct {
98+
Name string `json:"name"`
99+
}
100+
Expect(json.Unmarshal(rec.Body.Bytes(), &backends)).To(Succeed())
101+
102+
names := []string{}
103+
for _, b := range backends {
104+
names = append(names, b.Name)
105+
}
106+
return names
107+
}
108+
109+
Describe("fine-tune backends", func() {
110+
It("lists a backend installed only on a worker node", func() {
111+
names := listNames("/backends", ListFineTuneBackendsEndpoint(appCfg, nvidiaWorker, installedOnWorker))
112+
Expect(names).To(ContainElements("cpu-trainer", "gpu-trainer"))
113+
})
114+
115+
It("keeps single-node listings unchanged with no provider", func() {
116+
names := listNames("/backends", ListFineTuneBackendsEndpoint(appCfg, nil, nil))
117+
Expect(names).To(ContainElement("cpu-trainer"))
118+
Expect(names).NotTo(ContainElement("gpu-trainer"))
119+
})
120+
121+
It("degrades to the local listing when the registry errors", func() {
122+
names := listNames("/backends", ListFineTuneBackendsEndpoint(appCfg, nvidiaWorker, registryDown))
123+
Expect(names).To(ContainElement("cpu-trainer"))
124+
Expect(names).NotTo(ContainElement("gpu-trainer"))
125+
})
126+
})
127+
128+
Describe("quantization backends", func() {
129+
It("lists a backend installed only on a worker node", func() {
130+
names := listNames("/backends", ListQuantizationBackendsEndpoint(appCfg, nvidiaWorker, installedOnWorker))
131+
Expect(names).To(ContainElements("cpu-trainer", "gpu-trainer"))
132+
})
133+
134+
It("keeps single-node listings unchanged with no provider", func() {
135+
names := listNames("/backends", ListQuantizationBackendsEndpoint(appCfg, nil, nil))
136+
Expect(names).To(ContainElement("cpu-trainer"))
137+
Expect(names).NotTo(ContainElement("gpu-trainer"))
138+
})
139+
})
140+
141+
Describe("GET /backends/available", func() {
142+
installedFlags := func(capabilities ClusterCapabilityProvider, installed ClusterInstalledProvider) map[string]bool {
143+
e := echo.New()
144+
svc := CreateBackendEndpointService(galleries, systemState, nil, nil)
145+
e.GET("/backends/available", svc.ListAvailableBackendsEndpoint(systemState, capabilities, installed))
146+
147+
req := httptest.NewRequest(http.MethodGet, "/backends/available", nil)
148+
rec := httptest.NewRecorder()
149+
e.ServeHTTP(rec, req)
150+
Expect(rec.Code).To(Equal(http.StatusOK))
151+
152+
var backends []gallery.GalleryBackend
153+
Expect(json.Unmarshal(rec.Body.Bytes(), &backends)).To(Succeed())
154+
155+
flags := map[string]bool{}
156+
for _, b := range backends {
157+
flags[b.Name] = b.Installed
158+
}
159+
return flags
160+
}
161+
162+
It("reports a worker-installed backend as installed", func() {
163+
flags := installedFlags(nvidiaWorker, installedOnWorker)
164+
Expect(flags).To(HaveKeyWithValue("gpu-trainer", true))
165+
Expect(flags).To(HaveKeyWithValue("cpu-trainer", true))
166+
})
167+
168+
It("keeps single-node install state unchanged with no provider", func() {
169+
flags := installedFlags(nvidiaWorker, nil)
170+
Expect(flags).To(HaveKeyWithValue("gpu-trainer", false))
171+
Expect(flags).To(HaveKeyWithValue("cpu-trainer", true))
172+
})
173+
})
174+
})

core/http/endpoints/localai/finetune.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,9 +274,10 @@ func DownloadExportedModelEndpoint(ftService *finetune.FineTuneService) echo.Han
274274
}
275275

276276
// ListFineTuneBackendsEndpoint returns installed backends tagged with "fine-tuning".
277-
func ListFineTuneBackendsEndpoint(appConfig *config.ApplicationConfig, clusterCapabilities ClusterCapabilityProvider) echo.HandlerFunc {
277+
func ListFineTuneBackendsEndpoint(appConfig *config.ApplicationConfig, clusterCapabilities ClusterCapabilityProvider, clusterInstalled ClusterInstalledProvider) echo.HandlerFunc {
278278
return func(c echo.Context) error {
279279
capabilities := resolveClusterCapabilities(c.Request().Context(), clusterCapabilities)
280+
installed := resolveClusterInstalled(c.Request().Context(), clusterInstalled)
280281
backends, err := gallery.AvailableBackendsForCapabilities(appConfig.BackendGalleries, appConfig.SystemState, capabilities)
281282
if err != nil {
282283
return c.JSON(http.StatusInternalServerError, map[string]string{
@@ -292,7 +293,7 @@ func ListFineTuneBackendsEndpoint(appConfig *config.ApplicationConfig, clusterCa
292293

293294
var result []backendInfo
294295
for _, b := range backends {
295-
if !b.Installed {
296+
if !installedInCluster(b, installed) {
296297
continue
297298
}
298299
hasTag := false

core/http/endpoints/localai/quantization.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,9 +193,10 @@ func DownloadQuantizedModelEndpoint(qService *quantization.QuantizationService)
193193
}
194194

195195
// ListQuantizationBackendsEndpoint returns installed backends tagged with "quantization".
196-
func ListQuantizationBackendsEndpoint(appConfig *config.ApplicationConfig, clusterCapabilities ClusterCapabilityProvider) echo.HandlerFunc {
196+
func ListQuantizationBackendsEndpoint(appConfig *config.ApplicationConfig, clusterCapabilities ClusterCapabilityProvider, clusterInstalled ClusterInstalledProvider) echo.HandlerFunc {
197197
return func(c echo.Context) error {
198198
capabilities := resolveClusterCapabilities(c.Request().Context(), clusterCapabilities)
199+
installed := resolveClusterInstalled(c.Request().Context(), clusterInstalled)
199200
backends, err := gallery.AvailableBackendsForCapabilities(appConfig.BackendGalleries, appConfig.SystemState, capabilities)
200201
if err != nil {
201202
return c.JSON(http.StatusInternalServerError, map[string]string{
@@ -211,7 +212,7 @@ func ListQuantizationBackendsEndpoint(appConfig *config.ApplicationConfig, clust
211212

212213
var result []backendInfo
213214
for _, b := range backends {
214-
if !b.Installed {
215+
if !installedInCluster(b, installed) {
215216
continue
216217
}
217218
hasTag := false

core/http/routes/cluster_capabilities.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package routes
22

33
import (
4+
"context"
5+
46
"github.com/mudler/LocalAI/core/application"
57
"github.com/mudler/LocalAI/core/http/endpoints/localai"
68
)
@@ -18,3 +20,33 @@ func ClusterCapabilityProviderFor(app *application.Application) localai.ClusterC
1820
}
1921
return app.Distributed().Registry.HealthyBackendCapabilities
2022
}
23+
24+
// ClusterInstalledProviderFor returns the install-state source backing every
25+
// backend discovery endpoint that filters on installed backends, or nil in
26+
// single-node mode.
27+
//
28+
// A nil provider leaves those endpoints reading the local filesystem exactly as
29+
// they always have. In distributed mode a backend lives on the worker that runs
30+
// it, so the controller's own disk cannot answer the question; the active
31+
// BackendManager already aggregates the per-node view that GET /backends
32+
// renders, and discovery reuses it rather than growing a second path.
33+
func ClusterInstalledProviderFor(app *application.Application) localai.ClusterInstalledProvider {
34+
if app == nil || !app.IsDistributed() || app.GalleryService() == nil {
35+
return nil
36+
}
37+
return func(ctx context.Context) ([]string, error) {
38+
manager := app.GalleryService().BackendManager()
39+
if manager == nil {
40+
return nil, nil
41+
}
42+
backends, err := manager.ListBackends()
43+
if err != nil {
44+
return nil, err
45+
}
46+
names := make([]string, 0, len(backends))
47+
for name := range backends {
48+
names = append(names, name)
49+
}
50+
return names, nil
51+
}
52+
}

core/http/routes/finetuning.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func RegisterFineTuningRoutes(e *echo.Echo, ftService *finetune.FineTuneService,
2929
}
3030

3131
ft := e.Group("/api/fine-tuning", readyMw, fineTuningMw)
32-
ft.GET("/backends", localai.ListFineTuneBackendsEndpoint(appConfig, ClusterCapabilityProviderFor(app)))
32+
ft.GET("/backends", localai.ListFineTuneBackendsEndpoint(appConfig, ClusterCapabilityProviderFor(app), ClusterInstalledProviderFor(app)))
3333
ft.POST("/jobs", localai.StartFineTuneJobEndpoint(ftService))
3434
ft.GET("/jobs", localai.ListFineTuneJobsEndpoint(ftService))
3535
ft.GET("/jobs/:id", localai.GetFineTuneJobEndpoint(ftService))

core/http/routes/localai.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ func RegisterLocalAIRoutes(router *echo.Echo,
6464
router.POST("/backends/apply", backendGalleryEndpointService.ApplyBackendEndpoint(appConfig.SystemState), adminMiddleware)
6565
router.POST("/backends/delete/:name", backendGalleryEndpointService.DeleteBackendEndpoint(), adminMiddleware)
6666
router.GET("/backends", backendGalleryEndpointService.ListBackendsEndpoint(), adminMiddleware)
67-
router.GET("/backends/available", backendGalleryEndpointService.ListAvailableBackendsEndpoint(appConfig.SystemState, ClusterCapabilityProviderFor(app)), adminMiddleware)
67+
router.GET("/backends/available", backendGalleryEndpointService.ListAvailableBackendsEndpoint(appConfig.SystemState, ClusterCapabilityProviderFor(app), ClusterInstalledProviderFor(app)), adminMiddleware)
6868
router.GET("/backends/known", backendGalleryEndpointService.ListKnownBackendsEndpoint(appConfig.SystemState), adminMiddleware)
6969
router.GET("/backends/galleries", backendGalleryEndpointService.ListBackendGalleriesEndpoint(), adminMiddleware)
7070
router.GET("/backends/jobs/:uuid", backendGalleryEndpointService.GetOpStatusEndpoint(), adminMiddleware)

core/http/routes/quantization.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ func RegisterQuantizationRoutes(e *echo.Echo, qService *quantization.Quantizatio
2929
}
3030

3131
q := e.Group("/api/quantization", readyMw, quantizationMw)
32-
q.GET("/backends", localai.ListQuantizationBackendsEndpoint(appConfig, ClusterCapabilityProviderFor(app)))
32+
q.GET("/backends", localai.ListQuantizationBackendsEndpoint(appConfig, ClusterCapabilityProviderFor(app), ClusterInstalledProviderFor(app)))
3333
q.POST("/jobs", localai.StartQuantizationJobEndpoint(qService))
3434
q.GET("/jobs", localai.ListQuantizationJobsEndpoint(qService))
3535
q.GET("/jobs/:id", localai.GetQuantizationJobEndpoint(qService))

0 commit comments

Comments
 (0)