Files
LocalAI/core/http/endpoints/localai/gallery.go
T
Plamen K. Kosseff 7b9ef6a0f8 feat(backends): --disable-backend-install disables backend downloads. (#12754)
feat(backends): --disable-backend-install refuses every backend acquisition

A hardening switch for deployments that must run exactly the backend set they
were tested with (production, air-gapped, immutable images, package-managed
hosts). With LOCALAI_DISABLE_BACKEND_INSTALL=1 every install and upgrade path
refuses — gallery, OCI/URL/path, node-scoped, auto-upgrade, boot-time external
backends — HTTP routes answer 403 up front, and a model whose backend is
missing is refused before any download: 409 on the API, an error on preload
and import. Whether a backend is present is asked of the execution target:
the local backend directory on a single node, the workers' listings on a
distributed frontend, which installs nothing itself. Backends already
present, including system-path ones, keep working.

Assisted-by: Claude Code:claude-fable-5

Signed-off-by: Plamen K. Kosseff <p.kosseff@gmail.com>
2026-10-22 17:58:53 +02:00

210 lines
7.7 KiB
Go

package localai
import (
"encoding/json"
"fmt"
"net/http"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/xlog"
)
type ModelGalleryEndpointService struct {
appConfig *config.ApplicationConfig
galleries []config.Gallery
backendGalleries []config.Gallery
modelPath string
galleryApplier *galleryop.GalleryService
configLoader *config.ModelConfigLoader
}
// RefuseModelWithoutBackend answers 409 when backend installation is disabled
// and the model's backend is not already present: with no way to acquire it,
// the install is certain to produce an unusable model. It reports whether it
// wrote the refusal (the handler must then return its error and stop), so a
// nil write error is never mistaken for "not refused". Resolution errors are
// not refusals — the install path reports them in its own words.
func RefuseModelWithoutBackend(c echo.Context, appConfig *config.ApplicationConfig, galleries []config.Gallery, name, pin string, available gallery.BackendAvailability) (bool, error) {
if !appConfig.DisableBackendInstall {
return false, nil
}
backend, installed, err := gallery.RequiredBackendInstalled(c.Request().Context(), galleries, appConfig.SystemState, name, pin, available)
if err != nil || installed {
return false, nil
}
return true, c.JSON(http.StatusConflict, map[string]any{
"error": fmt.Sprintf("Model %q needs backend %q, which is not installed, and backend installation is disabled on this server.", name, backend),
"code": "backend_not_installed",
"backend": backend,
})
}
type GalleryModel struct {
ID string `json:"id"`
// Variant installs one specific build of an entry that declares variants,
// named as it appears in the entry's `variants` list (see the `variants`
// and `auto_variant` fields of the gallery listing). Leave it empty to let
// LocalAI auto-select the largest build this host can actually run.
Variant string `json:"variant,omitempty"`
gallery.GalleryModel
}
func CreateModelGalleryEndpointService(appConfig *config.ApplicationConfig, galleryApplier *galleryop.GalleryService, configLoader *config.ModelConfigLoader) ModelGalleryEndpointService {
return ModelGalleryEndpointService{
appConfig: appConfig,
galleries: appConfig.Galleries,
backendGalleries: appConfig.BackendGalleries,
modelPath: appConfig.SystemState.Model.ModelsPath,
galleryApplier: galleryApplier,
configLoader: configLoader,
}
}
// GetOpStatusEndpoint returns the job status
// @Summary Returns the job status
// @Tags models
// @Success 200 {object} galleryop.OpStatus "Response"
// @Router /models/jobs/{uuid} [get]
func (mgs *ModelGalleryEndpointService) GetOpStatusEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
status := mgs.galleryApplier.GetStatus(c.Param("uuid"))
if status == nil {
return fmt.Errorf("could not find any status for ID")
}
return c.JSON(200, status)
}
}
// GetAllStatusEndpoint returns all the jobs status progress
// @Summary Returns all the jobs status progress
// @Tags models
// @Success 200 {object} map[string]galleryop.OpStatus "Response"
// @Router /models/jobs [get]
func (mgs *ModelGalleryEndpointService) GetAllStatusEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
return c.JSON(200, mgs.galleryApplier.GetAllStatus())
}
}
// ApplyModelGalleryEndpoint installs a new model to a LocalAI instance from the model gallery
// @Summary Install models to LocalAI.
// @Tags models
// @Param request body GalleryModel true "query params"
// @Success 200 {object} schema.GalleryResponse "Response"
// @Failure 409 {object} map[string]interface{} "The model's backend is not installed and backend installation is disabled (code backend_not_installed)"
// @Router /models/apply [post]
func (mgs *ModelGalleryEndpointService) ApplyModelGalleryEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
input := new(GalleryModel)
// Get input data from the request body
if err := c.Bind(input); err != nil {
return err
}
if refused, err := RefuseModelWithoutBackend(c, mgs.appConfig, mgs.galleries, input.ID, input.Variant, mgs.galleryApplier.BackendManager().BackendAvailable); refused {
return err
}
uuid, err := uuid.NewUUID()
if err != nil {
return err
}
mgs.galleryApplier.EnqueueModelOp(galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
Req: input.GalleryModel,
ID: uuid.String(),
GalleryElementName: input.ID,
Variant: input.Variant,
Galleries: mgs.galleries,
BackendGalleries: mgs.backendGalleries,
})
return c.JSON(200, schema.GalleryResponse{ID: uuid.String(), StatusURL: fmt.Sprintf("%smodels/jobs/%s", middleware.BaseURL(c), uuid.String())})
}
}
// DeleteModelGalleryEndpoint lets delete models from a LocalAI instance
// @Summary delete models to LocalAI.
// @Tags models
// @Param name path string true "Model name"
// @Success 200 {object} schema.GalleryResponse "Response"
// @Router /models/delete/{name} [post]
func (mgs *ModelGalleryEndpointService) DeleteModelGalleryEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
modelName := c.Param("name")
uuid, err := uuid.NewUUID()
if err != nil {
return err
}
// The op carries the same ID the caller is handed back: without it the
// deletion ran under an empty ID and StatusURL pointed at a job that
// could never have a status.
mgs.galleryApplier.EnqueueModelOp(galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
ID: uuid.String(),
Delete: true,
GalleryElementName: modelName,
})
mgs.configLoader.RemoveModelConfig(modelName)
return c.JSON(200, schema.GalleryResponse{ID: uuid.String(), StatusURL: fmt.Sprintf("%smodels/jobs/%s", middleware.BaseURL(c), uuid.String())})
}
}
// ListModelFromGalleryEndpoint list the available models for installation from the active galleries
// @Summary List installable models.
// @Tags models
// @Success 200 {object} []gallery.Metadata "Response"
// @Router /models/available [get]
func (mgs *ModelGalleryEndpointService) ListModelFromGalleryEndpoint(systemState *system.SystemState) echo.HandlerFunc {
return func(c echo.Context) error {
models, err := gallery.AvailableGalleryModels(mgs.galleries, systemState)
if err != nil {
xlog.Error("could not list models from galleries", "error", err)
return err
}
xlog.Debug("Available models from galleries", "modelCount", len(models), "galleryCount", len(mgs.galleries))
m := []gallery.Metadata{}
for _, mm := range models {
m = append(m, mm.Metadata)
}
xlog.Debug("Models", "models", m)
dat, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("could not marshal models: %w", err)
}
return c.Blob(200, "application/json", dat)
}
}
// ListModelGalleriesEndpoint list the available galleries configured in LocalAI
// @Summary List all Galleries
// @Tags models
// @Success 200 {object} []config.Gallery "Response"
// @Router /models/galleries [get]
// NOTE: This is different (and much simpler!) than above! This JUST lists the model galleries that have been loaded, not their contents!
func (mgs *ModelGalleryEndpointService) ListModelGalleriesEndpoint() echo.HandlerFunc {
return func(c echo.Context) error {
xlog.Debug("Listing model galleries", "galleries", mgs.galleries)
dat, err := json.Marshal(mgs.galleries)
if err != nil {
return err
}
return c.Blob(200, "application/json", dat)
}
}