mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-13 06:45:26 -04:00
* fix(ui): move node labels into the scheduling selector field The scheduling page kept a node-label browser open above the rules whether or not anyone was writing one, while the field that actually needs labels, the rule's node selector, was two bare text inputs with no hint of what the cluster reports. The browser is gone. The selector's key input now completes against the label keys the cluster uses, and the value input offers only the values that key takes. The roster already loads for the page, so the suggestions cost no request, and a roster that fails to load costs the admin the hints and nothing else. Suggestions stay suggestions: a key no node reports yet still commits as typed, which is how an admin writes a rule before labelling the nodes for it. Assisted-by: Claude:claude-opus-5 golangci-lint eslint playwright Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(distributed): size model fit against the cluster, not the frontend The models page asked the frontend how much memory a model may occupy. In distributed mode the frontend is usually a GPU-less pod while every model runs on a worker, so a fleet of GPU nodes was told it could only run the smallest CPU build. The variant picker's fits flag and its auto-selection came from the same place, as did the hardware recommendations. The registry now reports the largest single healthy backend node. The largest node, not the fleet total: a model loads into one node, so four 16GB workers are not a home for a 40GB model. An operator-set VRAM budget caps a node's contribution, because the scheduler refuses a load above that ceiling anyway, and a GPU node beats a CPU node holding more system RAM. GET /api/resources and GET /api/models carry this as an additional cluster object. Their aggregate and ram fields keep reporting the frontend's own hardware, which is what the resource monitor shows. Variant selection judges backends against the union of the capabilities present in the cluster, the way backend discovery already did. Every path degrades to the local host: no cluster object in single-node mode, and none when the registry cannot be read, so a hiccup narrows the answer back to single-node behaviour rather than marking the whole catalog too large. The verdicts now name the node they belong to, since a model fits somewhere or nowhere. Assisted-by: Claude:claude-opus-5 golangci-lint eslint playwright 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>
246 lines
6.9 KiB
Go
246 lines
6.9 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, clusterInstalled ClusterInstalledProvider) echo.HandlerFunc {
|
|
return func(c echo.Context) error {
|
|
capabilities := ResolveClusterCapabilities(c.Request().Context(), clusterCapabilities)
|
|
installed := resolveClusterInstalled(c.Request().Context(), clusterInstalled)
|
|
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 !installedInCluster(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)
|
|
}
|
|
}
|