mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 18:38:23 -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>
245 lines
6.7 KiB
Go
245 lines
6.7 KiB
Go
package localai
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/gallery"
|
|
"github.com/mudler/LocalAI/core/schema"
|
|
"github.com/mudler/LocalAI/core/services/quantization"
|
|
)
|
|
|
|
// StartQuantizationJobEndpoint starts a new quantization job.
|
|
func StartQuantizationJobEndpoint(qService *quantization.QuantizationService) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userID := getUserID(c)
|
|
|
|
var req schema.QuantizationJobRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid request: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
if req.Model == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "model is required",
|
|
})
|
|
}
|
|
|
|
resp, err := qService.StartJob(c.Request().Context(), userID, req)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusCreated, resp)
|
|
}
|
|
}
|
|
|
|
// ListQuantizationJobsEndpoint lists quantization jobs for the current user.
|
|
func ListQuantizationJobsEndpoint(qService *quantization.QuantizationService) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userID := getUserID(c)
|
|
jobs := qService.ListJobs(userID)
|
|
if jobs == nil {
|
|
jobs = []*schema.QuantizationJob{}
|
|
}
|
|
return c.JSON(http.StatusOK, jobs)
|
|
}
|
|
}
|
|
|
|
// GetQuantizationJobEndpoint gets a specific quantization job.
|
|
func GetQuantizationJobEndpoint(qService *quantization.QuantizationService) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userID := getUserID(c)
|
|
jobID := c.Param("id")
|
|
|
|
job, err := qService.GetJob(userID, jobID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, job)
|
|
}
|
|
}
|
|
|
|
// StopQuantizationJobEndpoint stops a running quantization job.
|
|
func StopQuantizationJobEndpoint(qService *quantization.QuantizationService) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userID := getUserID(c)
|
|
jobID := c.Param("id")
|
|
|
|
err := qService.StopJob(c.Request().Context(), userID, jobID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{
|
|
"status": "stopped",
|
|
"message": "Quantization job stopped",
|
|
})
|
|
}
|
|
}
|
|
|
|
// DeleteQuantizationJobEndpoint deletes a quantization job and its data.
|
|
func DeleteQuantizationJobEndpoint(qService *quantization.QuantizationService) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userID := getUserID(c)
|
|
jobID := c.Param("id")
|
|
|
|
err := qService.DeleteJob(userID, jobID)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "not found") {
|
|
status = http.StatusNotFound
|
|
} else if strings.Contains(err.Error(), "cannot delete") {
|
|
status = http.StatusConflict
|
|
}
|
|
return c.JSON(status, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{
|
|
"status": "deleted",
|
|
"message": "Quantization job deleted",
|
|
})
|
|
}
|
|
}
|
|
|
|
// QuantizationProgressEndpoint streams progress updates via SSE.
|
|
func QuantizationProgressEndpoint(qService *quantization.QuantizationService) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userID := getUserID(c)
|
|
jobID := c.Param("id")
|
|
|
|
// Set SSE headers
|
|
c.Response().Header().Set("Content-Type", "text/event-stream")
|
|
c.Response().Header().Set("Cache-Control", "no-cache")
|
|
c.Response().Header().Set("Connection", "keep-alive")
|
|
c.Response().WriteHeader(http.StatusOK)
|
|
|
|
err := qService.StreamProgress(c.Request().Context(), userID, jobID, func(event *schema.QuantizationProgressEvent) {
|
|
data, err := json.Marshal(event)
|
|
if err != nil {
|
|
return
|
|
}
|
|
fmt.Fprintf(c.Response(), "data: %s\n\n", data)
|
|
c.Response().Flush()
|
|
})
|
|
if err != nil {
|
|
// If headers already sent, we can't send a JSON error
|
|
fmt.Fprintf(c.Response(), "data: {\"status\":\"error\",\"message\":%q}\n\n", err.Error())
|
|
c.Response().Flush()
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// ImportQuantizedModelEndpoint imports a quantized model into LocalAI.
|
|
func ImportQuantizedModelEndpoint(qService *quantization.QuantizationService) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userID := getUserID(c)
|
|
jobID := c.Param("id")
|
|
|
|
var req schema.QuantizationImportRequest
|
|
if err := c.Bind(&req); err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]string{
|
|
"error": "Invalid request: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
modelName, err := qService.ImportModel(c.Request().Context(), userID, jobID, req)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusAccepted, map[string]string{
|
|
"status": "importing",
|
|
"message": "Import started for model '" + modelName + "'",
|
|
"model_name": modelName,
|
|
})
|
|
}
|
|
}
|
|
|
|
// DownloadQuantizedModelEndpoint streams the quantized model file.
|
|
func DownloadQuantizedModelEndpoint(qService *quantization.QuantizationService) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
userID := getUserID(c)
|
|
jobID := c.Param("id")
|
|
|
|
outputPath, downloadName, err := qService.GetOutputPath(userID, jobID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusNotFound, map[string]string{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Attachment(outputPath, downloadName)
|
|
}
|
|
}
|
|
|
|
// ListQuantizationBackendsEndpoint returns installed backends tagged with "quantization".
|
|
func ListQuantizationBackendsEndpoint(appConfig *config.ApplicationConfig, clusterCapabilities ClusterCapabilityProvider) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
capabilities := resolveClusterCapabilities(c.Request().Context(), clusterCapabilities)
|
|
backends, err := gallery.AvailableBackendsForCapabilities(appConfig.BackendGalleries, appConfig.SystemState, capabilities)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]string{
|
|
"error": "failed to list backends: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
type backendInfo struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Tags []string `json:"tags,omitempty"`
|
|
}
|
|
|
|
var result []backendInfo
|
|
for _, b := range backends {
|
|
if !b.Installed {
|
|
continue
|
|
}
|
|
hasTag := false
|
|
for _, t := range b.Tags {
|
|
if strings.EqualFold(t, "quantization") {
|
|
hasTag = true
|
|
break
|
|
}
|
|
}
|
|
if !hasTag {
|
|
continue
|
|
}
|
|
name := b.Name
|
|
if b.Alias != "" {
|
|
name = b.Alias
|
|
}
|
|
result = append(result, backendInfo{
|
|
Name: name,
|
|
Description: b.Description,
|
|
Tags: b.Tags,
|
|
})
|
|
}
|
|
|
|
if result == nil {
|
|
result = []backendInfo{}
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, result)
|
|
}
|
|
}
|