feat(backends): install a backend from a library entry

A library can now list backends. An entry of kind backend names the
image of the backend gallery by digest, and POST /backends/library/install
installs it on the local node. The gallery stays the source of truth and
its install path is the one used: the library resolves the entry, takes
the signature policy from the library, pins the image by digest and calls
the same install as the gallery. Installing a name that came from a
gallery, another library or by hand is refused and changes nothing.

The signature policy of a backend library names the signer with an
anchored identityRegex, so images signed by the merge workflow on master
and on tags both verify. A pattern that is not anchored is refused.

An install is bounded by a size limit per kind: add-ons keep 512 MiB and
a backend image defaults to 8 GiB, set with --addons-backend-max-bytes.
The limit reads the manifest after the signature check and before any
layer is fetched. The metadata of an installed backend records the
library it came from.

scripts/backends/cmd/backendcatalog renders entries from
backend/index.yaml and pins them against the registry. The pilot is the
CPU build of local-store, in gallery/backends/index.yaml. The library is
registered on the first start only with --addons-backend-library, which
is off by default.

Assisted-by: Claude Code:claude-sonnet-5-5 go
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-10-21 20:15:10 +00:00
1 parent 484813f856
commit 1d8d3e3421
23 files changed
+1061 -17

No files matched your search

+20 -1
View File
@@ -17,6 +17,7 @@ import (
"github.com/mudler/LocalAI/core/http/auth"
mcpTools "github.com/mudler/LocalAI/core/http/endpoints/mcp"
"github.com/mudler/LocalAI/core/services/addons"
"github.com/mudler/LocalAI/core/services/addons/backendlib"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/core/services/addons/gateway"
"github.com/mudler/LocalAI/core/services/addons/packages"
@@ -80,6 +81,7 @@ type Application struct {
addonGateway *gateway.Gateway
addonServer *http.Server
addonSources *catalog.Sources
backendLibrary *backendlib.Installer
addonManager *addons.Manager
addonHostOps *addonruntime.HostOps
// The pin store is built once so the routes and the retention sweep share it.
@@ -824,6 +826,10 @@ func (a *Application) StartAgentPool() {
// AddonManager is nil unless the deployment explicitly enables addons.
func (a *Application) AddonManager() *addons.Manager { return a.addonManager }
// BackendLibrary installs backends from the library entries of kind backend.
// It is nil unless this instance owns the add-on libraries.
func (a *Application) BackendLibrary() *backendlib.Installer { return a.backendLibrary }
// AddonSources exposes owner-only source administration and explicit live refresh.
// Forwarders never load source configuration or perform catalog I/O.
func (a *Application) AddonSources() *catalog.Sources { return a.addonSources }
@@ -867,8 +873,15 @@ func (a *Application) startAddons() error {
if err != nil {
return err
}
var defaults []catalog.Source
if c.OfficialLibrary {
if _, err = sources.SeedDefault(catalog.OfficialLibrary()); err != nil {
defaults = append(defaults, catalog.OfficialLibrary())
}
if c.OfficialBackendLibrary {
defaults = append(defaults, catalog.OfficialBackendLibrary())
}
if len(defaults) > 0 {
if _, err = sources.SeedDefaults(defaults...); err != nil {
return err
}
}
@@ -936,6 +949,12 @@ func (a *Application) startAddons() error {
}
}
a.addonSources = cat.Sources
if cat.Sources != nil {
a.backendLibrary = &backendlib.Installer{
Catalog: cat, SystemState: a.applicationConfig.SystemState, ModelLoader: a.modelLoader,
MaxBytes: c.BackendMaxBytes, RequireIntegrity: a.applicationConfig.RequireBackendIntegrity,
}
}
a.addonManager = m
if err := a.startAddonGateway(); err != nil {
_ = m.Close()
+4
View File
@@ -37,6 +37,8 @@ type RunCMD struct {
AddonsEnabled bool `name:"addons-enabled" env:"LOCALAI_ADDONS_ENABLED" default:"true" help:"Run the add-on owner, which installs and supervises add-ons. Turn it off with --addons-enabled=false" group:"addons"`
AddonsOwnershipMode string `name:"addons-ownership-mode" env:"LOCALAI_ADDONS_OWNERSHIP_MODE" help:"Who may use add-ons: named-user (each account has its own access; needs authentication) or local-owner (every caller shares one owner). Default: local-owner when authentication is off, named-user when it is on" group:"addons"`
AddonsOfficialLibrary bool `name:"addons-official-library" env:"LOCALAI_ADDONS_OFFICIAL_LIBRARY" default:"true" help:"Register the official add-on library on the first start, when the add-on owner has no sources.json yet. A library the admin removes later stays removed. Turn it off with --addons-official-library=false" group:"addons"`
AddonsBackendLibrary bool `name:"addons-backend-library" env:"LOCALAI_ADDONS_BACKEND_LIBRARY" default:"false" help:"Also register the pilot backend library on the first start, when the add-on owner has no sources.json yet. The backend gallery stays the source of truth; a library backend is an additional way to install it" group:"addons"`
AddonsBackendMaxBytes int64 `name:"addons-backend-max-bytes" env:"LOCALAI_ADDONS_BACKEND_MAX_BYTES" default:"8589934592" help:"Largest compressed size, in bytes, of a backend image installed from a library. Add-ons keep their own limit of 512 MiB" group:"addons"`
Pprof bool `name:"pprof" env:"LOCALAI_PPROF" default:"false" help:"Enable the private loopback profiling listener" group:"diagnostics"`
PprofAddress string `name:"pprof-address" env:"LOCALAI_PPROF_ADDRESS" default:"127.0.0.1:6060" help:"Numeric loopback address and port for profiling" group:"diagnostics"`
PprofMutexProfileFraction int `name:"pprof-mutex-profile-fraction" env:"LOCALAI_PPROF_MUTEX_PROFILE_FRACTION" default:"0" help:"Mutex sampling fraction (0 disables sampling); requires pprof" group:"diagnostics"`
@@ -667,6 +669,8 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) {
}
if addonCfg.Enabled {
addonCfg.OfficialLibrary = r.AddonsOfficialLibrary
addonCfg.OfficialBackendLibrary = r.AddonsBackendLibrary
addonCfg.BackendMaxBytes = r.AddonsBackendMaxBytes
opts = append(opts, config.WithAddons(addonCfg))
}
if authEnabled {
+7
View File
@@ -36,6 +36,13 @@ type AddonConfig struct {
// when the owner has no sources.json yet. A sources.json that exists is
// never changed by it, so a library the admin removed stays removed.
OfficialLibrary bool
// OfficialBackendLibrary also registers the pilot library of backends on
// the first start. It is off by default: the backend gallery stays the
// source of truth and the library is an additional way to install.
OfficialBackendLibrary bool
// BackendMaxBytes bounds the compressed size of one backend image
// installed from a library; zero is 8 GiB. Add-ons keep their own limit.
BackendMaxBytes int64
Host contract.HostCompatibility
AdminCheck func(context.Context, contract.Actor) error `json:"-" yaml:"-"`
// BackupKeep is how many backups of one add-on the owner keeps; zero is
+9
View File
@@ -27,6 +27,9 @@ type BackendMetadata struct {
URI string `json:"uri,omitempty"`
// Digest is the OCI image digest at install time (for upgrade detection)
Digest string `json:"digest,omitempty"`
// Library is the id of the library the backend was installed from. It is
// empty for a backend installed from a gallery.
Library string `json:"library,omitempty"`
}
type GalleryBackend struct {
@@ -39,6 +42,12 @@ type GalleryBackend struct {
// SHA256 is the expected sha256 of the backend tarball at URI / Mirrors.
// Empty disables the integrity check; OCI URIs carry their own digest.
SHA256 string `json:"sha256,omitempty" yaml:"sha256,omitempty"`
// Library and MaxBytes are set by the install from a library, never read
// from a gallery file. Library records the origin in the metadata of the
// installed backend; MaxBytes bounds the compressed size of the image.
Library string `json:"-" yaml:"-"`
MaxBytes int64 `json:"-" yaml:"-"`
}
func (backend *GalleryBackend) FindBestBackendFromMeta(systemState *system.SystemState, backends GalleryElements[*GalleryBackend]) *GalleryBackend {
+4
View File
@@ -340,6 +340,9 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
return fmt.Errorf("backend %q: %w", config.Name, optsErr)
}
downloadOpts = append(downloadOpts, downloader.WithStagingDir(systemState.StagingPath))
if config.MaxBytes > 0 {
downloadOpts = append(downloadOpts, downloader.WithMaxBytes(config.MaxBytes))
}
// PreferDevelopmentBackends installs the development image as the primary URI,
// keeping the released image reachable as the first fallback — instead of only
@@ -446,6 +449,7 @@ func InstallBackend(ctx context.Context, systemState *system.SystemState, modelL
InstalledAt: time.Now().Format(time.RFC3339),
Version: config.Version,
URI: string(uri),
Library: config.Library,
}
// Record the OCI digest for upgrade detection (non-fatal on failure)
@@ -74,6 +74,23 @@ var _ = Describe("catalog and management actions", Label("catalogui"), func() {
s = newAppsStack()
})
It("lists add-ons by default, and takes a kind that this host reads", func() {
s.each(func(name string, e http.Handler) {
admin := bearerOf(s.tokens["alice"])
addons := send(e, "POST", "/api/addons/dispatch", dispatchBody("kind-addon-"+name, "", "catalog-search", 0, `{"kind":"addon"}`), admin)
Expect(addons.code).To(Equal(200), name)
Expect(payloadOf(addons)).To(HaveKeyWithValue("total", BeNumerically("==", 2)), name)
backends := send(e, "POST", "/api/addons/dispatch", dispatchBody("kind-backend-"+name, "", "catalog-search", 0, `{"kind":"backend"}`), admin)
Expect(backends.code).To(Equal(200), name)
Expect(payloadOf(backends)).To(HaveKeyWithValue("total", BeNumerically("==", 0)), name)
all := send(e, "POST", "/api/addons/dispatch", dispatchBody("kind-all-"+name, "", "catalog-search", 0, `{"kind":"all"}`), admin)
Expect(all.code).To(Equal(200), name)
Expect(payloadOf(all)).To(HaveKeyWithValue("total", BeNumerically("==", 2)), name)
model := send(e, "POST", "/api/addons/dispatch", dispatchBody("kind-model-"+name, "", "catalog-search", 0, `{"kind":"model"}`), admin)
Expect(model.code).To(Equal(403), name+": a kind this host does not read is a denied request")
})
})
It("serves catalog search, details and the installed management view to administrators only, the same from every frontend", func() {
s.each(func(name string, e http.Handler) {
admin := bearerOf(s.tokens["alice"])
+1 -1
View File
@@ -169,7 +169,7 @@ func resultError(c echo.Context, e error) error {
// Dispatch forwards typed operations to the explicitly configured owner.
// @Summary Submit a typed addon lifecycle operation or read an addon resource
// @Description Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, sourceId, offset, limit up to 100), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.
// @Description Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, kind, sourceId, offset, limit up to 100; kind is addon by default, or backend, or all), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.
// @Tags Addons
// @Accept json
// @Produce json
@@ -0,0 +1,77 @@
package localai
import (
"context"
"errors"
"net/http"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/addons/backendlib"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/xlog"
)
// BackendLibraryInstaller installs a backend from a library entry.
type BackendLibraryInstaller interface {
Install(ctx context.Context, sourceID, backend, version string, status func(string, string, string, float64)) error
}
// BackendLibraryInstallRequest names one library entry of kind backend.
type BackendLibraryInstallRequest struct {
// SourceID is the library the entry comes from.
SourceID string `json:"sourceId"`
// Name is the backend name of the entry.
Name string `json:"name"`
// Version is the version or the channel of the entry.
Version string `json:"version"`
}
// InstallBackendFromLibraryEndpoint installs a backend from a library entry
// on this node. The install runs inside the request and answers when it is
// done. The library resolves the entry, so the request cannot name an image,
// a digest or a policy.
// @Summary Install a backend from a library entry on this node.
// @Tags backends
// @Param request body BackendLibraryInstallRequest true "library, backend name and version"
// @Success 200 {object} map[string]string "installed"
// @Failure 404 {object} schema.ErrorResponse "no such entry"
// @Failure 409 {object} schema.ErrorResponse "the name is installed from another origin"
// @Failure 413 {object} schema.ErrorResponse "the image is over the size limit"
// @Router /backends/library/install [post]
func InstallBackendFromLibraryEndpoint(installer BackendLibraryInstaller) echo.HandlerFunc {
return func(c echo.Context) error {
var in BackendLibraryInstallRequest
if err := c.Bind(&in); err != nil {
return libraryError(c, http.StatusBadRequest, "invalid request")
}
if in.SourceID == "" || in.Name == "" || in.Version == "" {
return libraryError(c, http.StatusBadRequest, "sourceId, name and version are required")
}
err := installer.Install(c.Request().Context(), in.SourceID, in.Name, in.Version, nil)
if err == nil {
return c.JSON(http.StatusOK, map[string]string{"status": "installed", "name": in.Name, "sourceId": in.SourceID})
}
xlog.Warn("backend library install failed", "source", in.SourceID, "backend", in.Name, "error", err)
status := http.StatusInternalServerError
switch {
case errors.Is(err, catalog.ErrNotFound):
status = http.StatusNotFound
case errors.Is(err, backendlib.ErrCollision):
status = http.StatusConflict
case errors.Is(err, backendlib.ErrIntegrity), errors.Is(err, backendlib.ErrInvalid), errors.Is(err, catalog.ErrInvalid):
status = http.StatusBadRequest
case errors.Is(err, catalog.ErrUnavailable), errors.Is(err, catalog.ErrRefresh):
status = http.StatusServiceUnavailable
case errors.Is(err, downloader.ErrImageTooLarge):
status = http.StatusRequestEntityTooLarge
}
return libraryError(c, status, err.Error())
}
}
// libraryError answers in the error format of the other routes.
func libraryError(c echo.Context, status int, message string) error {
return c.JSON(status, schema.ErrorResponse{Error: &schema.APIError{Code: status, Message: message, Type: "backend_library"}})
}
@@ -0,0 +1,63 @@
package localai_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/http/endpoints/localai"
"github.com/mudler/LocalAI/core/services/addons/backendlib"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/pkg/downloader"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type fakeLibrary struct {
err error
last [3]string
}
func (f *fakeLibrary) Install(_ context.Context, source, backend, version string, _ func(string, string, string, float64)) error {
f.last = [3]string{source, backend, version}
return f.err
}
var _ = Describe("install a backend from a library", func() {
post := func(f *fakeLibrary, body string) *httptest.ResponseRecorder {
e := echo.New()
req := httptest.NewRequest(http.MethodPost, "/backends/library/install", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
Expect(localai.InstallBackendFromLibraryEndpoint(f)(e.NewContext(req, rec))).To(Succeed())
return rec
}
It("installs the named entry", func() {
f := &fakeLibrary{}
rec := post(f, `{"sourceId":"pilot","name":"cpu-demo","version":"latest"}`)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(f.last).To(Equal([3]string{"pilot", "cpu-demo", "latest"}))
})
It("refuses a request that does not name an entry", func() {
for _, body := range []string{`{}`, `{"sourceId":"pilot","name":"x"}`, `not json`} {
Expect(post(&fakeLibrary{}, body).Code).To(Equal(http.StatusBadRequest), body)
}
})
DescribeTable("maps what the install says to a status",
func(err error, status int) {
Expect(post(&fakeLibrary{err: err}, `{"sourceId":"p","name":"n","version":"v"}`).Code).To(Equal(status))
},
Entry("no such entry", catalog.ErrNotFound, http.StatusNotFound),
Entry("collision", fmt.Errorf("%w: %q", backendlib.ErrCollision, "n"), http.StatusConflict),
Entry("strict integrity", backendlib.ErrIntegrity, http.StatusBadRequest),
Entry("library unavailable", catalog.ErrUnavailable, http.StatusServiceUnavailable),
Entry("too large", fmt.Errorf("failed: %w", downloader.ErrImageTooLarge), http.StatusRequestEntityTooLarge),
Entry("anything else", fmt.Errorf("boom"), http.StatusInternalServerError),
)
})
+3
View File
@@ -79,6 +79,9 @@ func RegisterLocalAIRoutes(router *echo.Echo,
galleryService,
app.UpgradeChecker())
router.POST("/backends/apply", backendGalleryEndpointService.ApplyBackendEndpoint(appConfig.SystemState), adminMiddleware)
if lib := app.BackendLibrary(); lib != nil {
router.POST("/backends/library/install", localai.InstallBackendFromLibraryEndpoint(lib), adminMiddleware)
}
router.POST("/backends/delete/:name", backendGalleryEndpointService.DeleteBackendEndpoint(), adminMiddleware)
router.GET("/backends", backendGalleryEndpointService.ListBackendsEndpoint(), adminMiddleware)
router.GET("/backends/available", backendGalleryEndpointService.ListAvailableBackendsEndpoint(appConfig.SystemState, ClusterCapabilityProviderFor(app), ClusterInstalledProviderFor(app)), adminMiddleware)
+144
View File
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: MIT
// Package backendlib installs a backend from a library entry of kind backend.
//
// A library entry is one more way to see and install a backend. The backend
// gallery stays the source of truth and its install path is not changed: this
// package resolves the entry from the library the owner trusts, pins the image
// by digest, takes the signature policy from the library and then hands the
// install to the same code the gallery uses, so the backend ends up in the
// same directory with the same metadata plus the library it came from.
package backendlib
import (
"context"
"errors"
"fmt"
"os"
"regexp"
"strings"
"github.com/google/go-containerregistry/pkg/name"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
)
var (
// ErrCollision is returned when the name is already installed from another
// origin. Nothing is changed.
ErrCollision = errors.New("backend is already installed from another origin")
// ErrIntegrity is returned when strict integrity mode needs a signature
// policy and the library has none.
ErrIntegrity = errors.New("strict integrity: the library does not require a signature")
// ErrInvalid is returned for an entry that cannot be installed as given.
ErrInvalid = errors.New("invalid backend library entry")
)
// Installer installs library backends on the local node.
type Installer struct {
Catalog catalog.Catalog
SystemState *system.SystemState
ModelLoader *model.ModelLoader
// MaxBytes bounds the compressed size of the image. Zero means the default
// for the backend kind, catalog.DefaultMaxBytes(catalog.KindBackend).
MaxBytes int64
// RequireIntegrity is the strict mode of the gallery install
// (--require-backend-integrity): an entry whose library does not require a
// signature is refused.
RequireIntegrity bool
// install is the gallery install; tests replace it.
install func(context.Context, *system.SystemState, *model.ModelLoader, *gallery.GalleryBackend, func(string, string, string, float64), bool) error
}
func (i *Installer) limit() int64 {
if i.MaxBytes > 0 {
return i.MaxBytes
}
return catalog.DefaultMaxBytes(catalog.KindBackend)
}
// Install resolves the backend entry and installs it. version is the version
// or the channel of the entry, as for an add-on.
func (i *Installer) Install(ctx context.Context, sourceID, backend, version string, status func(string, string, string, float64)) error {
r, err := i.Catalog.ResolveKind(ctx, catalog.KindBackend, sourceID, backend, version)
if err != nil {
return err
}
gb, err := i.plan(r)
if err != nil {
return err
}
if err := i.checkCollision(r); err != nil {
return err
}
install := i.install
if install == nil {
install = gallery.InstallBackend
}
// With a verification policy the gallery install verifies the signature of
// the pinned image before it fetches any layer, then applies the size limit.
return install(ctx, i.SystemState, i.ModelLoader, gb, status, i.RequireIntegrity)
}
var digestForm = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
// plan turns a trusted resolution into the backend the gallery install takes.
// The image is named by digest, never by tag.
func (i *Installer) plan(r catalog.Release) (*gallery.GalleryBackend, error) {
if r.EntryKind() != catalog.KindBackend || !strings.HasPrefix(r.Artifact, "oci://") || !digestForm.MatchString(r.Digest) {
return nil, ErrInvalid
}
target, arch := i.Catalog.Target()
if r.OS != target || r.Arch != arch {
return nil, ErrInvalid
}
if i.RequireIntegrity && !r.Policy.RequireSignature {
return nil, fmt.Errorf("%w (library %q)", ErrIntegrity, r.SourceID)
}
ref, err := name.ParseReference(strings.TrimPrefix(r.Artifact, "oci://"))
if err != nil {
return nil, ErrInvalid
}
gb := &gallery.GalleryBackend{
URI: "oci://" + ref.Context().Digest(r.Digest).Name(),
Version: r.Version,
Alias: r.Alias,
Library: r.SourceID,
MaxBytes: i.limit(),
}
gb.Name = r.AddonID
gb.Gallery = config.Gallery{Name: "library:" + r.SourceID, URL: r.SourceURL}
if r.Policy.RequireSignature {
gb.Gallery.Verification = &config.GalleryVerification{
Issuer: r.Policy.Issuer,
Identity: r.Policy.Identity,
IdentityRegex: r.Policy.IdentityRegex,
}
}
return gb, nil
}
// checkCollision refuses to replace a backend that did not come from this
// library: one installed from a gallery, from another library or by hand.
// Installing again from the same library is a reinstall.
func (i *Installer) checkCollision(r catalog.Release) error {
backends, err := gallery.ListSystemBackends(i.SystemState)
if errors.Is(err, os.ErrNotExist) {
// No backends directory yet: nothing can collide.
return nil
}
if err != nil {
return err
}
have, ok := backends.Get(r.AddonID)
if !ok || have.IsSystem {
return nil
}
if have.Metadata == nil || have.Metadata.Library != r.SourceID {
return fmt.Errorf("%w: %q", ErrCollision, r.AddonID)
}
return nil
}
@@ -0,0 +1,198 @@
// SPDX-License-Identifier: MIT
package backendlib
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/go-containerregistry/pkg/crane"
"github.com/google/go-containerregistry/pkg/name"
"github.com/google/go-containerregistry/pkg/registry"
"github.com/google/go-containerregistry/pkg/v1/empty"
"github.com/google/go-containerregistry/pkg/v1/mutate"
"github.com/google/go-containerregistry/pkg/v1/remote"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestBackendLibrary(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Backend library install")
}
var ctx = context.Background()
func allow(context.Context, contract.Actor) error { return nil }
// push publishes a minimal backend image (a run.sh) and returns the registry
// host and the manifest digest.
func push(host, repo, tag, script string) string {
GinkgoHelper()
layer, err := crane.Layer(map[string][]byte{"run.sh": []byte(script)})
Expect(err).NotTo(HaveOccurred())
img, err := mutate.AppendLayers(empty.Image, layer)
Expect(err).NotTo(HaveOccurred())
ref, err := name.ParseReference(host + "/" + repo + ":" + tag)
Expect(err).NotTo(HaveOccurred())
Expect(remote.Write(ref, img)).To(Succeed())
d, err := img.Digest()
Expect(err).NotTo(HaveOccurred())
return d.String()
}
var _ = Describe("installing a backend from a library", func() {
var (
host string
backendsDir string
in *Installer
release catalog.Release
digest string
)
// serve starts a library that lists the release and refreshes it.
serve := func(r catalog.Release, policy catalog.VerificationPolicy) catalog.Catalog {
GinkgoHelper()
lib := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = catalog.WriteIndex(w, []catalog.Release{r})
}))
DeferCleanup(lib.Close)
s, err := catalog.NewSources(filepath.Join(GinkgoT().TempDir(), "sources.json"), allow, catalog.CredentialResolver{})
Expect(err).NotTo(HaveOccurred())
Expect(s.Save(ctx, contract.Actor{}, catalog.Source{ID: "pilot", Name: "Pilot", URL: lib.URL, Enabled: true, Policy: policy})).To(Succeed())
_, err = s.Refresh(ctx, "pilot")
Expect(err).NotTo(HaveOccurred())
return catalog.Catalog{Sources: s, TargetOS: "linux", TargetArch: "amd64"}
}
BeforeEach(func() {
reg := httptest.NewServer(registry.New())
DeferCleanup(reg.Close)
host = strings.TrimPrefix(reg.URL, "http://")
digest = push(host, "acme/backends", "latest-cpu-demo", "#!/bin/sh\necho demo\n")
release = catalog.Release{
Kind: catalog.KindBackend, AddonID: "cpu-demo", Alias: "demo", Name: "Demo (CPU)", Version: "0.0.0-latest.aaaaaaaaaaaa",
Artifact: "oci://" + host + "/acme/backends:latest-cpu-demo", Digest: digest, OS: "linux", Arch: "amd64",
}
backendsDir = GinkgoT().TempDir()
state, err := system.GetSystemState(system.WithBackendPath(backendsDir))
Expect(err).NotTo(HaveOccurred())
in = &Installer{Catalog: serve(release, catalog.VerificationPolicy{}), SystemState: state, ModelLoader: model.NewModelLoader(state)}
})
left := func() []string {
entries, err := os.ReadDir(backendsDir)
Expect(err).NotTo(HaveOccurred())
out := []string{}
for _, e := range entries {
out = append(out, e.Name())
}
return out
}
It("installs by digest and records the library, the digest and the alias", func() {
Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed())
Expect(filepath.Join(backendsDir, "cpu-demo", "run.sh")).To(BeARegularFile())
raw, err := os.ReadFile(filepath.Join(backendsDir, "cpu-demo", "metadata.json"))
Expect(err).NotTo(HaveOccurred())
var meta gallery.BackendMetadata
Expect(json.Unmarshal(raw, &meta)).To(Succeed())
Expect(meta.Library).To(Equal("pilot"))
Expect(meta.Digest).To(Equal(digest))
Expect(meta.Alias).To(Equal("demo"))
Expect(meta.Version).To(Equal(release.Version))
Expect(meta.URI).To(HaveSuffix("@" + digest))
// It is a backend like any other: the system lists it.
list, err := gallery.ListSystemBackends(in.SystemState)
Expect(err).NotTo(HaveOccurred())
Expect(list.Exists("cpu-demo")).To(BeTrue())
})
It("installs on a host that has no backends directory yet", func() {
fresh := filepath.Join(GinkgoT().TempDir(), "backends")
state, err := system.GetSystemState(system.WithBackendPath(fresh))
Expect(err).NotTo(HaveOccurred())
in.SystemState, in.ModelLoader = state, model.NewModelLoader(state)
Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed())
Expect(filepath.Join(fresh, "cpu-demo", "run.sh")).To(BeARegularFile())
})
It("installs again from the same library", func() {
Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed())
Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed())
})
It("refuses an entry whose digest is not in the registry and leaves nothing behind", func() {
bad := release
bad.Digest = "sha256:" + strings.Repeat("0", 64)
in.Catalog = serve(bad, catalog.VerificationPolicy{})
Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).NotTo(Succeed())
Expect(left()).To(BeEmpty())
})
It("refuses an image over the size limit and leaves nothing behind", func() {
in.MaxBytes = 1
err := in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)
Expect(err).To(MatchError(ContainSubstring(downloader.ErrImageTooLarge.Error())))
Expect(left()).To(BeEmpty())
})
It("uses 8 GiB as the default limit for a backend", func() {
Expect((&Installer{}).limit()).To(Equal(int64(8 << 30)))
Expect((&Installer{MaxBytes: 5}).limit()).To(Equal(int64(5)))
})
It("refuses to replace a backend that came from a gallery", func() {
dir := filepath.Join(backendsDir, "cpu-demo")
Expect(os.MkdirAll(dir, 0o750)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "run.sh"), []byte("#!/bin/sh\necho gallery\n"), 0o755)).To(Succeed())
Expect(os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(`{"name":"cpu-demo","gallery_url":"https://gallery.example/index.yaml"}`), 0o644)).To(Succeed())
Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(MatchError(ErrCollision))
run, err := os.ReadFile(filepath.Join(dir, "run.sh"))
Expect(err).NotTo(HaveOccurred())
Expect(string(run)).To(ContainSubstring("gallery"))
Expect(left()).To(Equal([]string{"cpu-demo"}))
})
It("refuses a library without a signature policy in strict integrity mode", func() {
in.RequireIntegrity = true
Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(MatchError(ErrIntegrity))
Expect(left()).To(BeEmpty())
})
It("does not install an add-on entry or an unknown name", func() {
Expect(in.Install(ctx, "pilot", "missing", "1.0.0", nil)).To(MatchError(catalog.ErrNotFound))
Expect(in.Install(ctx, "nowhere", "cpu-demo", release.Version, nil)).NotTo(Succeed())
})
It("takes the signature policy of the library, with the identity pattern, and pins the image by digest", func() {
pattern := `^https://github\.com/o/r/\.github/workflows/w\.yml@refs/(heads/master|tags/.+)$`
policy := catalog.VerificationPolicy{RequireSignature: true, Issuer: "https://issuer.example", IdentityRegex: pattern}
in.Catalog = serve(release, policy)
var got *gallery.GalleryBackend
in.install = func(_ context.Context, _ *system.SystemState, _ *model.ModelLoader, gb *gallery.GalleryBackend, _ func(string, string, string, float64), strict bool) error {
got = gb
return nil
}
in.RequireIntegrity = true
Expect(in.Install(ctx, "pilot", "cpu-demo", release.Version, nil)).To(Succeed())
Expect(got.URI).To(Equal("oci://" + host + "/acme/backends@" + digest))
Expect(got.Gallery.Verification).NotTo(BeNil())
Expect(got.Gallery.Verification.Issuer).To(Equal("https://issuer.example"))
Expect(got.Gallery.Verification.IdentityRegex).To(Equal(pattern))
Expect(got.Gallery.Verification.Identity).To(BeEmpty())
Expect(got.MaxBytes).To(Equal(int64(8 << 30)))
Expect(got.Library).To(Equal("pilot"))
})
})
+17
View File
@@ -88,3 +88,20 @@ func (q Query) matchesKind(r Release) bool {
func validQueryKind(k Kind) bool {
return k == "" || k == QueryAllKinds || slices.Contains(ReadableKinds, k)
}
// Default size limits for one install, by kind. The limit is on the compressed
// size of the package or image. An add-on is a small package; a backend image
// can hold a runtime and its libraries, so its default is larger and the owner
// can change it.
const (
DefaultAddonMaxBytes int64 = 512 << 20
DefaultBackendMaxBytes int64 = 8 << 30
)
// DefaultMaxBytes is the default size limit of an install of the kind.
func DefaultMaxBytes(k Kind) int64 {
if k == KindBackend {
return DefaultBackendMaxBytes
}
return DefaultAddonMaxBytes
}
+26
View File
@@ -26,3 +26,29 @@ func OfficialLibrary() Source {
},
}
}
const (
// OfficialBackendLibraryID is the source ID of the pilot backend library.
OfficialBackendLibraryID = "official-backends"
// OfficialBackendLibraryURL is the YAML index of the pilot backends, kept
// in this repository at gallery/backends/index.yaml. It is rendered from
// backend/index.yaml, which stays the source of truth.
OfficialBackendLibraryURL = "https://raw.githubusercontent.com/mudler/LocalAI/master/gallery/backends/index.yaml"
)
// OfficialBackendLibrary is the pilot library of backends. It requires the
// signature of the backend merge workflow, from master or from a tag, with the
// same signer pattern the backend gallery uses.
func OfficialBackendLibrary() Source {
return Source{
ID: OfficialBackendLibraryID,
Name: "Official backends (pilot)",
URL: OfficialBackendLibraryURL,
Enabled: true,
Policy: VerificationPolicy{
RequireSignature: true,
Issuer: "https://token.actions.githubusercontent.com",
IdentityRegex: `^https://github\.com/mudler/LocalAI/\.github/workflows/backend_merge\.yml@refs/(heads/master|tags/.+)$`,
},
}
}
+10 -4
View File
@@ -298,16 +298,22 @@ func (s *Sources) persist(next map[string]Source) error {
// removed (the file then holds the remaining libraries, possibly none) and a
// library the admin edited is not reset. It reports whether it added src.
// It is trusted startup code and takes no actor; it never runs for a request.
func (s *Sources) SeedDefault(src Source) (bool, error) {
func (s *Sources) SeedDefault(src Source) (bool, error) { return s.SeedDefaults(src) }
// SeedDefaults is SeedDefault for several libraries, written together.
func (s *Sources) SeedDefaults(srcs ...Source) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.fresh {
return false, nil
}
if !idPattern.MatchString(src.ID) || src.Removed || !validSource(src) {
return false, ErrInvalid
next := map[string]Source{}
for _, src := range srcs {
if !idPattern.MatchString(src.ID) || src.Removed || !validSource(src) {
return false, ErrInvalid
}
next[src.ID] = src
}
next := map[string]Source{src.ID: src}
if e := s.persist(next); e != nil {
return false, e
}
+1 -1
View File
@@ -48,7 +48,7 @@ func (i *Installer) byteLimit() int64 {
if i.MaxBytes > 0 {
return i.MaxBytes
}
return 512 << 20
return catalog.DefaultMaxBytes(catalog.KindAddon)
}
func (i *Installer) fileLimit() int {
if i.MaxFiles > 0 {
+20
View File
@@ -0,0 +1,20 @@
# Pilot backend library: backends from the backend gallery, one entry for each
# image. The gallery, backend/index.yaml, stays the source of truth. The entries
# are rendered with scripts/backends/cmd/backendcatalog, so the digests pin the
# images the gallery names today: render the file again after the backend images
# are rebuilt, never edit a digest by hand.
- addonId: cpu-local-store
alias: local-store
arch: amd64
artifact: oci://quay.io/go-skynet/local-ai-backends:latest-cpu-local-store
channel: latest
description: Local Store is a local-first, self-hosted, and open-source vector database.
digest: sha256:0a1056fd863328adf9d1097808aa2b52f68c4ca2e5385100a754755b3ca6218f
kind: backend
name: cpu-local-store
os: linux
policy:
identityRegex: ^https://github\.com/mudler/LocalAI/\.github/workflows/backend_merge\.yml@refs/(heads/master|tags/.+)$
issuer: https://token.actions.githubusercontent.com
requireSignature: true
version: 0.0.0-latest.0a1056fd8633
+39
View File
@@ -16,6 +16,7 @@ import (
"sync"
"time"
v1 "github.com/google/go-containerregistry/pkg/v1"
"github.com/google/go-containerregistry/pkg/v1/tarball"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
@@ -72,6 +73,7 @@ type downloadOptions struct {
bearerToken string
transferProgress TransferProgressSink
stagingDir string
maxBytes int64
}
// DownloadOption configures DownloadFileWithContext / DownloadFile.
@@ -94,6 +96,14 @@ func WithBearerToken(token string) DownloadOption {
return func(o *downloadOptions) { o.bearerToken = token }
}
// WithMaxBytes refuses an OCI image whose compressed layers add up to more
// than n bytes. The check reads the manifest and runs after signature
// verification and before any layer is fetched. It does nothing for other
// download kinds, and n <= 0 sets no limit.
func WithMaxBytes(n int64) DownloadOption {
return func(o *downloadOptions) { o.maxBytes = n }
}
func WithStagingDir(dir string) DownloadOption {
return func(o *downloadOptions) { o.stagingDir = dir }
}
@@ -658,6 +668,12 @@ func (uri URI) DownloadFileWithContext(ctx context.Context, filePath, sha string
xlog.Info("Image signature verified", "ref", pinned)
}
if dopts.maxBytes > 0 {
if cerr := checkImageSize(img, dopts.maxBytes); cerr != nil {
return fmt.Errorf("image %q: %w", url, cerr)
}
}
return oci.ExtractOCIImage(ctx, img, url, filePath, dopts.stagingDir, downloadStatus)
}
@@ -981,3 +997,26 @@ func CalculateSHA(filePath string) (string, error) {
return fmt.Sprintf("%x", hash.Sum(nil)), nil
}
// ErrImageTooLarge is returned when an image is over the limit of WithMaxBytes.
var ErrImageTooLarge = errors.New("image is larger than the size limit")
// checkImageSize sums the compressed layer sizes the manifest declares.
func checkImageSize(img v1.Image, limit int64) error {
layers, err := img.Layers()
if err != nil {
return fmt.Errorf("reading layers: %w", err)
}
var total int64
for _, l := range layers {
size, err := l.Size()
if err != nil || size < 0 {
return fmt.Errorf("reading layer size: %w", err)
}
if size > limit-total {
return fmt.Errorf("%w (limit %d bytes)", ErrImageTooLarge, limit)
}
total += size
}
return nil
}
+151
View File
@@ -0,0 +1,151 @@
// SPDX-License-Identifier: MIT
// Command backendcatalog renders library entries of kind backend from the
// backend gallery, backend/index.yaml. The gallery stays the source of truth:
// this command reads it and pins what it names, it never edits it.
//
// For each backend named on the command line it finds the concrete gallery
// entry, resolves the image the entry names to the digest of the image for the
// platform, and writes one library entry. The registry is read at render time,
// so the digests are never edited by hand: render the file again after the
// backend images are rebuilt.
//
// go run ./scripts/backends/cmd/backendcatalog \
// --index backend/index.yaml --backends cpu-local-store \
// --out gallery/backends/index.yaml
package main
import (
"bytes"
"errors"
"flag"
"fmt"
"os"
"regexp"
"strings"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services/addons/catalog"
"github.com/mudler/LocalAI/pkg/oci"
"gopkg.in/yaml.v3"
)
const header = `# Pilot backend library: backends from the backend gallery, one entry for each
# image. The gallery, backend/index.yaml, stays the source of truth. The entries
# are rendered with scripts/backends/cmd/backendcatalog, so the digests pin the
# images the gallery names today: render the file again after the backend images
# are rebuilt, never edit a digest by hand.
`
// resolver returns the manifest digest of the image for a platform.
type resolver func(image, platform string) (string, error)
func registryResolver(image, platform string) (string, error) {
img, err := oci.GetImage(image, platform, nil, nil)
if err != nil {
return "", err
}
d, err := img.Digest()
if err != nil {
return "", err
}
return d.String(), nil
}
var tagPattern = regexp.MustCompile(`:([a-z][a-z0-9]*)-[A-Za-z0-9._-]+$`)
// render builds the library entries of the named backends for one platform.
func render(gallerySource []byte, names []string, osName, arch string, resolve resolver) ([]catalog.Release, error) {
var entries []gallery.GalleryBackend
if err := yaml.Unmarshal(gallerySource, &entries); err != nil {
return nil, fmt.Errorf("reading the backend gallery: %w", err)
}
byName := map[string]gallery.GalleryBackend{}
for _, e := range entries {
byName[e.Name] = e
}
var out []catalog.Release
for _, name := range names {
e, ok := byName[name]
if !ok {
return nil, fmt.Errorf("backend %q is not in the gallery", name)
}
if e.IsMeta() || e.URI == "" {
return nil, fmt.Errorf("backend %q is a meta backend; name a concrete one", name)
}
image := strings.TrimPrefix(e.URI, "oci://")
m := tagPattern.FindStringSubmatch(image)
if m == nil || m[1] != "latest" {
return nil, fmt.Errorf("backend %q: the image %q is not a released latest-* tag", name, image)
}
digest, err := resolve(image, osName+"/"+arch)
if err != nil {
return nil, fmt.Errorf("backend %q: resolving %s: %w", name, image, err)
}
hex := strings.TrimPrefix(digest, "sha256:")
if len(hex) != 64 {
return nil, fmt.Errorf("backend %q: unexpected digest %q", name, digest)
}
out = append(out, catalog.Release{
Kind: catalog.KindBackend,
AddonID: e.Name,
Alias: e.Alias,
Name: e.Name,
Description: strings.Join(strings.Fields(e.Description), " "),
Version: "0.0.0-latest." + hex[:12],
Channel: "latest",
Artifact: "oci://" + image,
Digest: digest,
OS: osName,
Arch: arch,
// The host replaces this with the policy of the library; it is
// written so the file says what the entries are checked against.
Policy: catalog.OfficialBackendLibrary().Policy,
})
}
return out, nil
}
func run(args []string) error {
fs := flag.NewFlagSet("backendcatalog", flag.ContinueOnError)
index := fs.String("index", "backend/index.yaml", "the backend gallery")
backends := fs.String("backends", "", "comma list of concrete backend names")
platform := fs.String("platform", "linux/amd64", "os/arch")
out := fs.String("out", "", "file to write (default: standard output)")
if err := fs.Parse(args); err != nil {
return err
}
osName, arch, ok := strings.Cut(*platform, "/")
if !ok || *backends == "" {
return errors.New("usage: --backends a,b [--platform os/arch] [--index file] [--out file]")
}
src, err := os.ReadFile(*index)
if err != nil {
return err
}
releases, err := render(src, strings.Split(*backends, ","), osName, arch, registryResolver)
if err != nil {
return err
}
var buf bytes.Buffer
buf.WriteString(header)
if err := catalog.WriteIndex(&buf, releases); err != nil {
return err
}
// The result must be a valid library index for this host.
if _, err := catalog.DecodeIndex(buf.Bytes()); err != nil {
return fmt.Errorf("the rendered index is not valid: %w", err)
}
if *out == "" {
_, err = os.Stdout.Write(buf.Bytes())
return err
}
return os.WriteFile(*out, buf.Bytes(), 0o644)
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "backendcatalog:", err)
os.Exit(1)
}
}
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: MIT
package main
import (
"strings"
"testing"
"github.com/mudler/LocalAI/core/services/addons/catalog"
)
const fakeGallery = `
- &demo
name: "demo"
description: |
A demo
backend.
capabilities:
default: "cpu-demo"
- !!merge <<: *demo
name: "cpu-demo"
alias: "demo"
uri: "quay.io/example/backends:latest-cpu-demo"
- !!merge <<: *demo
name: "cpu-demo-development"
uri: "quay.io/example/backends:master-cpu-demo"
`
func fakeDigest(image, platform string) (string, error) {
return "sha256:" + strings.Repeat("ab", 32), nil
}
func TestRenderPinsTheGalleryImage(t *testing.T) {
got, err := render([]byte(fakeGallery), []string{"cpu-demo"}, "linux", "amd64", fakeDigest)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 {
t.Fatalf("want 1 entry, got %d", len(got))
}
r := got[0]
if r.EntryKind() != catalog.KindBackend || r.AddonID != "cpu-demo" || r.Alias != "demo" || r.Channel != "latest" ||
r.Artifact != "oci://quay.io/example/backends:latest-cpu-demo" || r.Version != "0.0.0-latest.abababababab" ||
r.Description != "A demo backend." {
t.Fatalf("unexpected entry: %+v", r)
}
data, err := catalog.EncodeIndex(got)
if err != nil {
t.Fatal(err)
}
read, err := catalog.ReadIndex(data, catalog.KindBackend)
if err != nil || len(read.Releases) != 1 || len(read.Skipped) != 0 {
t.Fatalf("the rendered entry does not read back: %v %+v", err, read)
}
}
func TestRenderRefusesWhatItCannotPin(t *testing.T) {
for name, tc := range map[string][]string{
"unknown": {"nope"},
"meta": {"demo"},
"master only": {"cpu-demo-development"},
} {
if _, err := render([]byte(fakeGallery), tc, "linux", "amd64", fakeDigest); err == nil {
t.Errorf("%s: want an error", name)
}
}
}
+66 -1
View File
@@ -696,7 +696,7 @@ const docTemplate = `{
},
"/api/addons/dispatch": {
"post": {
"description": "Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, sourceId, offset, limit up to 100), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.",
"description": "Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, kind, sourceId, offset, limit up to 100; kind is addon by default, or backend, or all), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.",
"consumes": [
"application/json"
],
@@ -5702,6 +5702,54 @@ const docTemplate = `{
}
}
},
"/backends/library/install": {
"post": {
"tags": [
"backends"
],
"summary": "Install a backend from a library entry on this node.",
"parameters": [
{
"description": "library, backend name and version",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/localai.BackendLibraryInstallRequest"
}
}
],
"responses": {
"200": {
"description": "installed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "no such entry",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
},
"409": {
"description": "the name is installed from another origin",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
},
"413": {
"description": "the image is over the size limit",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
}
}
}
},
"/backends/upgrade/{name}": {
"post": {
"tags": [
@@ -9609,6 +9657,23 @@ const docTemplate = `{
}
}
},
"localai.BackendLibraryInstallRequest": {
"type": "object",
"properties": {
"name": {
"description": "Name is the backend name of the entry.",
"type": "string"
},
"sourceId": {
"description": "SourceID is the library the entry comes from.",
"type": "string"
},
"version": {
"description": "Version is the version or the channel of the entry.",
"type": "string"
}
}
},
"localai.BrandingResponse": {
"type": "object",
"properties": {
+66 -1
View File
@@ -693,7 +693,7 @@
},
"/api/addons/dispatch": {
"post": {
"description": "Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, sourceId, offset, limit up to 100), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.",
"description": "Authenticates the named caller, checks the addons feature, requires administration for every action except read, and forwards the typed request to the designated addon owner. A mutation is never retried: after an outcome_unknown response keep the operationId and poll operation-status instead of submitting a new mutation. Lifecycle actions are install, upgrade, enable, disable and uninstall (arguments sourceId and version; enable and disable take the generation that was listed). The management reads, all administrator-only and never mutations, are catalog-search (arguments text, kind, sourceId, offset, limit up to 100; kind is addon by default, or backend, or all), catalog-details (addonId plus arguments sourceId and version), catalog-sources and addons-installed. The library changes are catalog-source-save (arguments are one source: id, name, url, enabled, policy, authReference; removed true deletes it and never touches installed addons) and catalog-source-refresh (argument sourceId). A library names a credential only by a reference the owner configured, never a secret. Unknown argument fields are denied. The update actions are update-status and update-check (no arguments; the check fetches the library again and applies nothing) and update-upgrade (arguments expectedDigest and candidateDigest, the pinned upgrade); each answers with a payload {error: {type, message, status}} or {data}, the same from every frontend. The browser pages use the dedicated routes under /api/addons/{id}/update and /api/addons/{id}/upgrade, which accept only an administrator's own browser session.",
"consumes": [
"application/json"
],
@@ -5699,6 +5699,54 @@
}
}
},
"/backends/library/install": {
"post": {
"tags": [
"backends"
],
"summary": "Install a backend from a library entry on this node.",
"parameters": [
{
"description": "library, backend name and version",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/localai.BackendLibraryInstallRequest"
}
}
],
"responses": {
"200": {
"description": "installed",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"404": {
"description": "no such entry",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
},
"409": {
"description": "the name is installed from another origin",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
},
"413": {
"description": "the image is over the size limit",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
}
}
}
},
"/backends/upgrade/{name}": {
"post": {
"tags": [
@@ -9606,6 +9654,23 @@
}
}
},
"localai.BackendLibraryInstallRequest": {
"type": "object",
"properties": {
"name": {
"description": "Name is the backend name of the entry.",
"type": "string"
},
"sourceId": {
"description": "SourceID is the library the entry comes from.",
"type": "string"
},
"version": {
"description": "Version is the version or the channel of the entry.",
"type": "string"
}
}
},
"localai.BrandingResponse": {
"type": "object",
"properties": {
+52 -8
View File
@@ -1551,6 +1551,18 @@ definitions:
target:
type: string
type: object
localai.BackendLibraryInstallRequest:
properties:
name:
description: Name is the backend name of the entry.
type: string
sourceId:
description: SourceID is the library the entry comes from.
type: string
version:
description: Version is the version or the channel of the entry.
type: string
type: object
localai.BrandingResponse:
properties:
favicon_url:
@@ -6398,14 +6410,15 @@ paths:
a new mutation. Lifecycle actions are install, upgrade, enable, disable and
uninstall (arguments sourceId and version; enable and disable take the generation
that was listed). The management reads, all administrator-only and never mutations,
are catalog-search (arguments text, sourceId, offset, limit up to 100), catalog-details
(addonId plus arguments sourceId and version), catalog-sources and addons-installed.
The library changes are catalog-source-save (arguments are one source: id,
name, url, enabled, policy, authReference; removed true deletes it and never
touches installed addons) and catalog-source-refresh (argument sourceId).
A library names a credential only by a reference the owner configured, never
a secret. Unknown argument fields are denied. The update actions are update-status
and update-check (no arguments; the check fetches the library again and applies
are catalog-search (arguments text, kind, sourceId, offset, limit up to 100;
kind is addon by default, or backend, or all), catalog-details (addonId plus
arguments sourceId and version), catalog-sources and addons-installed. The
library changes are catalog-source-save (arguments are one source: id, name,
url, enabled, policy, authReference; removed true deletes it and never touches
installed addons) and catalog-source-refresh (argument sourceId). A library
names a credential only by a reference the owner configured, never a secret.
Unknown argument fields are denied. The update actions are update-status and
update-check (no arguments; the check fetches the library again and applies
nothing) and update-upgrade (arguments expectedDigest and candidateDigest,
the pinned upgrade); each answers with a payload {error: {type, message, status}}
or {data}, the same from every frontend. The browser pages use the dedicated
@@ -9524,6 +9537,37 @@ paths:
summary: List all known Backends (importer registry + curated pref-only + installed-on-disk)
tags:
- backends
/backends/library/install:
post:
parameters:
- description: library, backend name and version
in: body
name: request
required: true
schema:
$ref: '#/definitions/localai.BackendLibraryInstallRequest'
responses:
"200":
description: installed
schema:
additionalProperties:
type: string
type: object
"404":
description: no such entry
schema:
$ref: '#/definitions/schema.ErrorResponse'
"409":
description: the name is installed from another origin
schema:
$ref: '#/definitions/schema.ErrorResponse'
"413":
description: the image is over the size limit
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Install a backend from a library entry on this node.
tags:
- backends
/backends/upgrade/{name}:
post:
parameters: