feat(ui): add mask to install custom backends (#7559)

* feat: allow to install backends from URL in the WebUI and API

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* tests

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* trace backends installations

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2025-12-13 19:11:32 +01:00
committed by GitHub
parent 7790a24682
commit e1874cdb54
10 changed files with 750 additions and 93 deletions

View File

@@ -11,9 +11,9 @@ import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services"
coreStartup "github.com/mudler/LocalAI/core/startup"
"github.com/mudler/LocalAI/internal"
coreStartup "github.com/mudler/LocalAI/core/startup"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/xsysinfo"
"github.com/rs/zerolog/log"
@@ -75,7 +75,7 @@ func New(opts ...config.AppOption) (*Application, error) {
}
for _, backend := range options.ExternalBackends {
if err := coreStartup.InstallExternalBackends(options.Context, options.BackendGalleries, options.SystemState, application.ModelLoader(), nil, backend, "", ""); err != nil {
if err := services.InstallExternalBackend(options.Context, options.BackendGalleries, options.SystemState, application.ModelLoader(), nil, backend, "", ""); err != nil {
log.Error().Err(err).Msg("error installing external backend")
}
}

View File

@@ -7,11 +7,11 @@ import (
cliContext "github.com/mudler/LocalAI/core/cli/context"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/startup"
"github.com/rs/zerolog/log"
"github.com/schollz/progressbar/v3"
)
@@ -103,7 +103,7 @@ func (bi *BackendsInstall) Run(ctx *cliContext.Context) error {
}
modelLoader := model.NewModelLoader(systemState)
err = startup.InstallExternalBackends(context.Background(), galleries, systemState, modelLoader, progressCallback, bi.BackendArgs, bi.Name, bi.Alias)
err = services.InstallExternalBackend(context.Background(), galleries, systemState, modelLoader, progressCallback, bi.BackendArgs, bi.Name, bi.Alias)
if err != nil {
return err
}

View File

@@ -81,13 +81,17 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
}
// Determine if it's a model or backend
isBackend := false
backends, _ := gallery.AvailableBackends(appConfig.BackendGalleries, appConfig.SystemState)
for _, b := range backends {
backendID := fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)
if backendID == galleryID || b.Name == galleryID {
isBackend = true
break
// First check if it was explicitly marked as a backend operation
isBackend := opcache.IsBackendOp(galleryID)
// If not explicitly marked, check if it matches a known backend from the gallery
if !isBackend {
backends, _ := gallery.AvailableBackends(appConfig.BackendGalleries, appConfig.SystemState)
for _, b := range backends {
backendID := fmt.Sprintf("%s@%s", b.Gallery.Name, b.Name)
if backendID == galleryID || b.Name == galleryID {
isBackend = true
break
}
}
}
@@ -645,7 +649,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
}
uid := id.String()
opcache.Set(backendID, uid)
opcache.SetBackend(backendID, uid)
ctx, cancelFunc := context.WithCancel(context.Background())
op := services.GalleryOp[gallery.GalleryBackend, any]{
@@ -667,6 +671,70 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
})
})
// Install backend from external source (OCI image, URL, or path)
app.POST("/api/backends/install-external", func(c echo.Context) error {
// Request body structure
type ExternalBackendRequest struct {
URI string `json:"uri"`
Name string `json:"name"`
Alias string `json:"alias"`
}
var req ExternalBackendRequest
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]interface{}{
"error": "invalid request body",
})
}
// Validate required fields
if req.URI == "" {
return c.JSON(http.StatusBadRequest, map[string]interface{}{
"error": "uri is required",
})
}
log.Debug().Str("uri", req.URI).Str("name", req.Name).Str("alias", req.Alias).Msg("API job submitted to install external backend")
id, err := uuid.NewUUID()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]interface{}{
"error": err.Error(),
})
}
uid := id.String()
// Use URI as the key for opcache, or name if provided
cacheKey := req.URI
if req.Name != "" {
cacheKey = req.Name
}
opcache.SetBackend(cacheKey, uid)
ctx, cancelFunc := context.WithCancel(context.Background())
op := services.GalleryOp[gallery.GalleryBackend, any]{
ID: uid,
GalleryElementName: req.Name, // May be empty, will be derived during installation
Galleries: appConfig.BackendGalleries,
Context: ctx,
CancelFunc: cancelFunc,
ExternalURI: req.URI,
ExternalName: req.Name,
ExternalAlias: req.Alias,
}
// Store cancellation function immediately so queued operations can be cancelled
galleryService.StoreCancellation(uid, cancelFunc)
go func() {
galleryService.BackendGalleryChannel <- op
}()
return c.JSON(200, map[string]interface{}{
"jobID": uid,
"message": "External backend installation started",
})
})
app.POST("/api/backends/delete/:id", func(c echo.Context) error {
backendID := c.Param("id")
// URL decode the backend ID
@@ -692,7 +760,7 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
uid := id.String()
opcache.Set(backendID, uid)
opcache.SetBackend(backendID, uid)
ctx, cancelFunc := context.WithCancel(context.Background())
op := services.GalleryOp[gallery.GalleryBackend, any]{

View File

@@ -0,0 +1,210 @@
package routes_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/core/http/routes"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestRoutes(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Routes Suite")
}
var _ = Describe("Backend API Routes", func() {
var (
app *echo.Echo
tempDir string
appConfig *config.ApplicationConfig
galleryService *services.GalleryService
modelLoader *model.ModelLoader
systemState *system.SystemState
configLoader *config.ModelConfigLoader
)
BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "backend-routes-test-*")
Expect(err).NotTo(HaveOccurred())
systemState, err = system.GetSystemState(
system.WithBackendPath(filepath.Join(tempDir, "backends")),
)
Expect(err).NotTo(HaveOccurred())
systemState.Model.ModelsPath = filepath.Join(tempDir, "models")
// Create directories
err = os.MkdirAll(systemState.Backend.BackendsPath, 0750)
Expect(err).NotTo(HaveOccurred())
err = os.MkdirAll(systemState.Model.ModelsPath, 0750)
Expect(err).NotTo(HaveOccurred())
modelLoader = model.NewModelLoader(systemState)
configLoader = config.NewModelConfigLoader(tempDir)
appConfig = config.NewApplicationConfig(
config.WithContext(context.Background()),
)
appConfig.SystemState = systemState
appConfig.BackendGalleries = []config.Gallery{}
galleryService = services.NewGalleryService(appConfig, modelLoader)
// Start the gallery service
err = galleryService.Start(context.Background(), configLoader, systemState)
Expect(err).NotTo(HaveOccurred())
app = echo.New()
// Register the API routes for backends
opcache := services.NewOpCache(galleryService)
routes.RegisterUIAPIRoutes(app, configLoader, modelLoader, appConfig, galleryService, opcache, nil)
})
AfterEach(func() {
os.RemoveAll(tempDir)
})
Describe("POST /api/backends/install-external", func() {
It("should return error when URI is missing", func() {
reqBody := map[string]string{
"name": "test-backend",
}
jsonBody, err := json.Marshal(reqBody)
Expect(err).NotTo(HaveOccurred())
req := httptest.NewRequest(http.MethodPost, "/api/backends/install-external", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
var response map[string]interface{}
err = json.Unmarshal(rec.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(response["error"]).To(Equal("uri is required"))
})
It("should accept valid request and return job ID", func() {
reqBody := map[string]string{
"uri": "oci://quay.io/example/backend:latest",
"name": "test-backend",
"alias": "test-alias",
}
jsonBody, err := json.Marshal(reqBody)
Expect(err).NotTo(HaveOccurred())
req := httptest.NewRequest(http.MethodPost, "/api/backends/install-external", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
var response map[string]interface{}
err = json.Unmarshal(rec.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(response["jobID"]).NotTo(BeEmpty())
Expect(response["message"]).To(Equal("External backend installation started"))
})
It("should accept request with only URI", func() {
reqBody := map[string]string{
"uri": "/path/to/local/backend",
}
jsonBody, err := json.Marshal(reqBody)
Expect(err).NotTo(HaveOccurred())
req := httptest.NewRequest(http.MethodPost, "/api/backends/install-external", bytes.NewBuffer(jsonBody))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
var response map[string]interface{}
err = json.Unmarshal(rec.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(response["jobID"]).NotTo(BeEmpty())
})
It("should return error for invalid JSON body", func() {
req := httptest.NewRequest(http.MethodPost, "/api/backends/install-external", bytes.NewBufferString("invalid json"))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
})
})
Describe("GET /api/backends/job/:uid", func() {
It("should return queued status for unknown job", func() {
req := httptest.NewRequest(http.MethodGet, "/api/backends/job/unknown-job-id", nil)
rec := httptest.NewRecorder()
app.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
var response map[string]interface{}
err := json.Unmarshal(rec.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(response["queued"]).To(Equal(true))
Expect(response["processed"]).To(Equal(false))
})
})
})
// Helper function to make POST request
func postRequest(url string, body interface{}) (*http.Response, error) {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{}
return client.Do(req)
}
// Helper function to read response body
func readResponseBody(resp *http.Response) (map[string]interface{}, error) {
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var result map[string]interface{}
err = json.Unmarshal(body, &result)
return result, err
}
// Avoid unused import errors
var _ = gallery.GalleryModel{}

View File

@@ -65,6 +65,66 @@
{{template "views/partials/inprogress" .}}
<!-- Manual Backend Installation Form (Collapsible) -->
<div class="card p-6 mb-8">
<button
@click="showManualInstall = !showManualInstall"
class="w-full flex items-center justify-between text-left"
>
<div class="flex items-center gap-2">
<i class="fas fa-plus-circle text-[#38BDF8] text-lg"></i>
<h3 class="text-lg font-semibold text-[#E5E7EB]">Install Backend Manually</h3>
</div>
<i class="fas text-[#94A3B8] transition-transform duration-200" :class="showManualInstall ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
</button>
<div x-show="showManualInstall" x-collapse>
<p class="text-sm text-[#94A3B8] mt-4 mb-6">Install a backend from an OCI image, URL, or local path</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-[#94A3B8] mb-2">OCI Image / URL / Path *</label>
<input
type="text"
x-model="externalBackend.uri"
placeholder="e.g., oci://quay.io/example/backend:latest"
class="w-full px-4 py-3 text-sm bg-[#101827] border border-[#38BDF8]/30 rounded-lg text-[#E5E7EB] placeholder-[#94A3B8]/50 focus:border-[#38BDF8] focus:outline-none focus:ring-1 focus:ring-[#38BDF8]"
>
</div>
<div>
<label class="block text-sm font-medium text-[#94A3B8] mb-2">Name (required for OCI)</label>
<input
type="text"
x-model="externalBackend.name"
placeholder="e.g., my-backend"
class="w-full px-4 py-3 text-sm bg-[#101827] border border-[#38BDF8]/30 rounded-lg text-[#E5E7EB] placeholder-[#94A3B8]/50 focus:border-[#38BDF8] focus:outline-none focus:ring-1 focus:ring-[#38BDF8]"
>
</div>
<div>
<label class="block text-sm font-medium text-[#94A3B8] mb-2">Alias (optional)</label>
<input
type="text"
x-model="externalBackend.alias"
placeholder="e.g., backend-alias"
class="w-full px-4 py-3 text-sm bg-[#101827] border border-[#38BDF8]/30 rounded-lg text-[#E5E7EB] placeholder-[#94A3B8]/50 focus:border-[#38BDF8] focus:outline-none focus:ring-1 focus:ring-[#38BDF8]"
>
</div>
</div>
<div class="flex items-center gap-4">
<button
@click="installExternalBackend()"
:disabled="installingExternal || !externalBackend.uri"
class="inline-flex items-center px-5 py-2.5 rounded-lg bg-[#38BDF8] hover:bg-[#38BDF8]/80 text-sm font-medium text-white transition duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<i class="mr-2" :class="installingExternal ? 'fas fa-spinner fa-spin' : 'fas fa-download'"></i>
<span x-text="installingExternal ? 'Installing...' : 'Install Backend'"></span>
</button>
<span x-show="externalBackendProgress" class="text-sm text-[#94A3B8]" x-text="externalBackendProgress"></span>
</div>
</div>
</div>
<!-- Search and Filter Section -->
<div class="card p-8 mb-8">
<div>
@@ -533,6 +593,16 @@ function backendsGallery() {
notifications: [],
sortBy: '',
sortOrder: 'asc',
// External backend installation state
showManualInstall: false,
externalBackend: {
uri: '',
name: '',
alias: ''
},
installingExternal: false,
externalBackendJobID: null,
externalBackendProgress: '',
init() {
this.fetchBackends();
@@ -551,6 +621,46 @@ function backendsGallery() {
this.notifications = this.notifications.filter(n => n.id !== id);
},
async installExternalBackend() {
if (this.installingExternal || !this.externalBackend.uri) {
return;
}
try {
this.installingExternal = true;
this.externalBackendProgress = 'Starting installation...';
const response = await fetch('/api/backends/install-external', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
uri: this.externalBackend.uri,
name: this.externalBackend.name,
alias: this.externalBackend.alias
})
});
const data = await response.json();
if (response.ok && data.jobID) {
this.externalBackendJobID = data.jobID;
const displayName = this.externalBackend.name || this.externalBackend.uri;
this.addNotification(`Installing backend "${displayName}"...`, 'success');
} else {
this.installingExternal = false;
this.externalBackendProgress = '';
this.addNotification(`Failed to start installation: ${data.error || 'Unknown error'}`, 'error');
}
} catch (error) {
console.error('Error installing external backend:', error);
this.installingExternal = false;
this.externalBackendProgress = '';
this.addNotification(`Failed to install backend: ${error.message}`, 'error');
}
},
async fetchBackends() {
this.loading = true;
try {
@@ -715,6 +825,52 @@ function backendsGallery() {
// Don't show notification for every polling error, only if backend is stuck
}
}
// Poll for external backend installation job
if (this.externalBackendJobID) {
try {
const response = await fetch(`/api/backends/job/${this.externalBackendJobID}`);
const jobData = await response.json();
// Update progress message
if (jobData.message && !jobData.processed) {
this.externalBackendProgress = jobData.message;
if (jobData.progress) {
this.externalBackendProgress += ` (${Math.round(jobData.progress)}%)`;
}
}
if (jobData.completed) {
const displayName = this.externalBackend.name || this.externalBackend.uri;
this.addNotification(`Backend "${displayName}" installed successfully!`, 'success');
this.externalBackendJobID = null;
this.installingExternal = false;
this.externalBackendProgress = '';
// Reset form
this.externalBackend = { uri: '', name: '', alias: '' };
// Refresh the backends list
this.fetchBackends();
}
if (jobData.error || (jobData.message && jobData.message.startsWith('error:'))) {
let errorMessage = 'Unknown error';
if (typeof jobData.error === 'string') {
errorMessage = jobData.error;
} else if (jobData.message) {
errorMessage = jobData.message;
}
if (errorMessage.startsWith('error: ')) {
errorMessage = errorMessage.substring(7);
}
this.addNotification(`Error installing backend: ${errorMessage}`, 'error');
this.externalBackendJobID = null;
this.installingExternal = false;
this.externalBackendProgress = '';
}
} catch (error) {
console.error('Error polling external backend job:', error);
}
}
},
renderMarkdown(text) {

View File

@@ -59,7 +59,11 @@
<span class="font-semibold text-purple-300" x-text="repositories.length"></span>
<span class="text-[var(--color-text-secondary)] ml-1">repositories</span>
</div>
<a href="https://localai.io/models/" target="_blank" class="btn-primary">
<a href="/import-model" class="btn-primary">
<i class="fas fa-upload mr-2"></i>
<span>Import Model</span>
</a>
<a href="https://localai.io/models/" target="_blank" class="btn-secondary">
<i class="fas fa-info-circle mr-2"></i>
<span>Documentation</span>
<i class="fas fa-external-link-alt ml-2 text-xs"></i>

View File

@@ -4,8 +4,13 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/utils"
@@ -55,7 +60,16 @@ func (g *GalleryService) backendHandler(op *GalleryOp[gallery.GalleryBackend, an
if op.Delete {
err = gallery.DeleteBackendFromSystem(g.appConfig.SystemState, op.GalleryElementName)
g.modelLoader.DeleteExternalBackend(op.GalleryElementName)
} else if op.ExternalURI != "" {
// External backend installation (OCI image, URL, or path)
log.Info().Str("uri", op.ExternalURI).Str("name", op.ExternalName).Str("alias", op.ExternalAlias).Msg("Installing external backend")
err = InstallExternalBackend(ctx, g.appConfig.BackendGalleries, systemState, g.modelLoader, progressCallback, op.ExternalURI, op.ExternalName, op.ExternalAlias)
// Update GalleryElementName for status tracking if a name was derived
if op.ExternalName != "" {
op.GalleryElementName = op.ExternalName
}
} else {
// Standard gallery installation
log.Warn().Msgf("installing backend %s", op.GalleryElementName)
log.Debug().Msgf("backend galleries: %v", g.appConfig.BackendGalleries)
err = gallery.InstallBackendFromGallery(ctx, g.appConfig.BackendGalleries, systemState, g.modelLoader, op.GalleryElementName, progressCallback, true)
@@ -89,3 +103,73 @@ func (g *GalleryService) backendHandler(op *GalleryOp[gallery.GalleryBackend, an
Cancellable: false})
return nil
}
// InstallExternalBackend installs a backend from an external source (OCI image, URL, or path).
// This method contains the logic to detect the input type and call the appropriate installation function.
// It can be used by both CLI and Web UI for installing backends from external sources.
func InstallExternalBackend(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, modelLoader *model.ModelLoader, downloadStatus func(string, string, string, float64), backend, name, alias string) error {
uri := downloader.URI(backend)
switch {
case uri.LooksLikeDir():
if name == "" { // infer it from the path
name = filepath.Base(backend)
}
log.Info().Str("backend", backend).Str("name", name).Msg("Installing backend from path")
if err := gallery.InstallBackend(ctx, systemState, modelLoader, &gallery.GalleryBackend{
Metadata: gallery.Metadata{
Name: name,
},
Alias: alias,
URI: backend,
}, downloadStatus); err != nil {
return fmt.Errorf("error installing backend %s: %w", backend, err)
}
case uri.LooksLikeOCI() && !uri.LooksLikeOCIFile():
if name == "" {
return fmt.Errorf("specifying a name is required for OCI images")
}
log.Info().Str("backend", backend).Str("name", name).Msg("Installing backend from OCI image")
if err := gallery.InstallBackend(ctx, systemState, modelLoader, &gallery.GalleryBackend{
Metadata: gallery.Metadata{
Name: name,
},
Alias: alias,
URI: backend,
}, downloadStatus); err != nil {
return fmt.Errorf("error installing backend %s: %w", backend, err)
}
case uri.LooksLikeOCIFile():
derivedName, err := uri.FilenameFromUrl()
if err != nil {
return fmt.Errorf("failed to get filename from URL: %w", err)
}
// strip extension if any
derivedName = strings.TrimSuffix(derivedName, filepath.Ext(derivedName))
// Use provided name if available, otherwise use derived name
if name == "" {
name = derivedName
}
log.Info().Str("backend", backend).Str("name", name).Msg("Installing backend from OCI image")
if err := gallery.InstallBackend(ctx, systemState, modelLoader, &gallery.GalleryBackend{
Metadata: gallery.Metadata{
Name: name,
},
Alias: alias,
URI: backend,
}, downloadStatus); err != nil {
return fmt.Errorf("error installing backend %s: %w", backend, err)
}
default:
// Treat as gallery backend name
if name != "" || alias != "" {
return fmt.Errorf("specifying a name or alias is not supported for gallery backends")
}
err := gallery.InstallBackendFromGallery(ctx, galleries, systemState, modelLoader, backend, downloadStatus, true)
if err != nil {
return fmt.Errorf("error installing backend %s: %w", backend, err)
}
}
return nil
}

View File

@@ -0,0 +1,193 @@
package services_test
import (
"context"
"os"
"path/filepath"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/services"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v2"
)
var _ = Describe("InstallExternalBackend", func() {
var (
tempDir string
galleries []config.Gallery
ml *model.ModelLoader
systemState *system.SystemState
)
BeforeEach(func() {
var err error
tempDir, err = os.MkdirTemp("", "backends-service-test-*")
Expect(err).NotTo(HaveOccurred())
systemState, err = system.GetSystemState(system.WithBackendPath(tempDir))
Expect(err).NotTo(HaveOccurred())
ml = model.NewModelLoader(systemState)
// Setup test gallery
galleries = []config.Gallery{
{
Name: "test-gallery",
URL: "file://" + filepath.Join(tempDir, "test-gallery.yaml"),
},
}
})
AfterEach(func() {
os.RemoveAll(tempDir)
})
Context("with gallery backend name", func() {
BeforeEach(func() {
// Create a test gallery file with a test backend
testBackend := []map[string]interface{}{
{
"name": "test-backend",
"uri": "https://gist.githubusercontent.com/mudler/71d5376bc2aa168873fa519fa9f4bd56/raw/testbackend/run.sh",
},
}
data, err := yaml.Marshal(testBackend)
Expect(err).NotTo(HaveOccurred())
err = os.WriteFile(filepath.Join(tempDir, "test-gallery.yaml"), data, 0644)
Expect(err).NotTo(HaveOccurred())
})
It("should fail when name or alias is provided for gallery backend", func() {
err := services.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
"test-backend", // gallery name
"custom-name", // name should not be allowed
"",
)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("specifying a name or alias is not supported for gallery backends"))
})
It("should fail when backend is not found in gallery", func() {
err := services.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
"non-existent-backend",
"",
"",
)
Expect(err).To(HaveOccurred())
})
})
Context("with OCI image", func() {
It("should fail when name is not provided for OCI image", func() {
err := services.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
"oci://quay.io/mudler/tests:localai-backend-test",
"", // name is required for OCI images
"",
)
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("specifying a name is required for OCI images"))
})
})
Context("with directory path", func() {
var testBackendPath string
BeforeEach(func() {
// Create a test backend directory with required files
testBackendPath = filepath.Join(tempDir, "source-backend")
err := os.MkdirAll(testBackendPath, 0750)
Expect(err).NotTo(HaveOccurred())
// Create run.sh
err = os.WriteFile(filepath.Join(testBackendPath, "run.sh"), []byte("#!/bin/bash\necho test"), 0755)
Expect(err).NotTo(HaveOccurred())
})
It("should infer name from directory path when name is not provided", func() {
// This test verifies that the function attempts to install using the directory name
// The actual installation may fail due to test environment limitations
err := services.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
testBackendPath,
"", // name should be inferred as "source-backend"
"",
)
// The function should at least attempt to install with the inferred name
// Even if it fails for other reasons, it shouldn't fail due to missing name
if err != nil {
Expect(err.Error()).NotTo(ContainSubstring("name is required"))
}
})
It("should use provided name when specified", func() {
err := services.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
testBackendPath,
"custom-backend-name",
"",
)
// The function should use the provided name
if err != nil {
Expect(err.Error()).NotTo(ContainSubstring("name is required"))
}
})
It("should support alias when provided", func() {
err := services.InstallExternalBackend(
context.Background(),
galleries,
systemState,
ml,
nil,
testBackendPath,
"custom-backend-name",
"custom-alias",
)
// The function should accept alias for directory paths
if err != nil {
Expect(err.Error()).NotTo(ContainSubstring("alias is not supported"))
}
})
})
})
var _ = Describe("GalleryOp with External Backend", func() {
It("should have external backend fields in GalleryOp", func() {
// Test that the GalleryOp struct has the new external backend fields
op := services.GalleryOp[string, string]{
ExternalURI: "oci://example.com/backend:latest",
ExternalName: "test-backend",
ExternalAlias: "test-alias",
}
Expect(op.ExternalURI).To(Equal("oci://example.com/backend:latest"))
Expect(op.ExternalName).To(Equal("test-backend"))
Expect(op.ExternalAlias).To(Equal("test-alias"))
})
})

View File

@@ -23,6 +23,12 @@ type GalleryOp[T any, E any] struct {
// Context for cancellation support
Context context.Context
CancelFunc context.CancelFunc
// External backend installation parameters (for OCI/URL/path)
// These are used when installing backends from external sources rather than galleries
ExternalURI string // The OCI image, URL, or path
ExternalName string // Custom name for the backend
ExternalAlias string // Custom alias for the backend
}
type GalleryOpStatus struct {
@@ -41,12 +47,14 @@ type GalleryOpStatus struct {
type OpCache struct {
status *xsync.SyncedMap[string, string]
backendOps *xsync.SyncedMap[string, bool] // Tracks which operations are backend operations
galleryService *GalleryService
}
func NewOpCache(galleryService *GalleryService) *OpCache {
return &OpCache{
status: xsync.NewSyncedMap[string, string](),
backendOps: xsync.NewSyncedMap[string, bool](),
galleryService: galleryService,
}
}
@@ -55,6 +63,17 @@ func (m *OpCache) Set(key string, value string) {
m.status.Set(key, value)
}
// SetBackend sets a key-value pair and marks it as a backend operation
func (m *OpCache) SetBackend(key string, value string) {
m.status.Set(key, value)
m.backendOps.Set(key, true)
}
// IsBackendOp returns true if the given key is a backend operation
func (m *OpCache) IsBackendOp(key string) bool {
return m.backendOps.Get(key)
}
func (m *OpCache) Get(key string) string {
return m.status.Get(key)
}
@@ -63,6 +82,7 @@ func (m *OpCache) DeleteUUID(uuid string) {
for _, k := range m.status.Keys() {
if m.status.Get(k) == uuid {
m.status.Delete(k)
m.backendOps.Delete(k) // Also clean up the backend flag
}
}
}

View File

@@ -1,78 +0,0 @@
package startup
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/gallery"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/LocalAI/pkg/system"
"github.com/rs/zerolog/log"
)
func InstallExternalBackends(ctx context.Context, galleries []config.Gallery, systemState *system.SystemState, modelLoader *model.ModelLoader, downloadStatus func(string, string, string, float64), backend, name, alias string) error {
uri := downloader.URI(backend)
switch {
case uri.LooksLikeDir():
if name == "" { // infer it from the path
name = filepath.Base(backend)
}
log.Info().Str("backend", backend).Str("name", name).Msg("Installing backend from path")
if err := gallery.InstallBackend(ctx, systemState, modelLoader, &gallery.GalleryBackend{
Metadata: gallery.Metadata{
Name: name,
},
Alias: alias,
URI: backend,
}, downloadStatus); err != nil {
return fmt.Errorf("error installing backend %s: %w", backend, err)
}
case uri.LooksLikeOCI() && !uri.LooksLikeOCIFile():
if name == "" {
return fmt.Errorf("specifying a name is required for OCI images")
}
log.Info().Str("backend", backend).Str("name", name).Msg("Installing backend from OCI image")
if err := gallery.InstallBackend(ctx, systemState, modelLoader, &gallery.GalleryBackend{
Metadata: gallery.Metadata{
Name: name,
},
Alias: alias,
URI: backend,
}, downloadStatus); err != nil {
return fmt.Errorf("error installing backend %s: %w", backend, err)
}
case uri.LooksLikeOCIFile():
name, err := uri.FilenameFromUrl()
if err != nil {
return fmt.Errorf("failed to get filename from URL: %w", err)
}
// strip extension if any
name = strings.TrimSuffix(name, filepath.Ext(name))
log.Info().Str("backend", backend).Str("name", name).Msg("Installing backend from OCI image")
if err := gallery.InstallBackend(ctx, systemState, modelLoader, &gallery.GalleryBackend{
Metadata: gallery.Metadata{
Name: name,
},
Alias: alias,
URI: backend,
}, downloadStatus); err != nil {
return fmt.Errorf("error installing backend %s: %w", backend, err)
}
default:
if name != "" || alias != "" {
return fmt.Errorf("specifying a name or alias is not supported for this backend")
}
err := gallery.InstallBackendFromGallery(ctx, galleries, systemState, modelLoader, backend, downloadStatus, true)
if err != nil {
return fmt.Errorf("error installing backend %s: %w", backend, err)
}
}
return nil
}