diff --git a/core/application/startup.go b/core/application/startup.go
index 490fee24e..3a238655d 100644
--- a/core/application/startup.go
+++ b/core/application/startup.go
@@ -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")
}
}
diff --git a/core/cli/backends.go b/core/cli/backends.go
index aa32a40e4..e95137b30 100644
--- a/core/cli/backends.go
+++ b/core/cli/backends.go
@@ -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
}
diff --git a/core/http/routes/ui_api.go b/core/http/routes/ui_api.go
index f6e57905c..9287b3174 100644
--- a/core/http/routes/ui_api.go
+++ b/core/http/routes/ui_api.go
@@ -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]{
diff --git a/core/http/routes/ui_api_backends_test.go b/core/http/routes/ui_api_backends_test.go
new file mode 100644
index 000000000..b611d403e
--- /dev/null
+++ b/core/http/routes/ui_api_backends_test.go
@@ -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{}
diff --git a/core/http/views/backends.html b/core/http/views/backends.html
index b5ce41594..4c5aa51d6 100644
--- a/core/http/views/backends.html
+++ b/core/http/views/backends.html
@@ -65,6 +65,66 @@
{{template "views/partials/inprogress" .}}
+
+
+
+
+
+
Install a backend from an OCI image, URL, or local path
+
+
+
+
+
+
+
+
+
+
@@ -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) {
diff --git a/core/http/views/models.html b/core/http/views/models.html
index 42ae8317c..9387ce13d 100644
--- a/core/http/views/models.html
+++ b/core/http/views/models.html
@@ -59,7 +59,11 @@
repositories
-
+
+
+ Import Model
+
+
Documentation
diff --git a/core/services/backends.go b/core/services/backends.go
index 6eb69bbc1..0070b4e4c 100644
--- a/core/services/backends.go
+++ b/core/services/backends.go
@@ -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
+}
diff --git a/core/services/backends_test.go b/core/services/backends_test.go
new file mode 100644
index 000000000..077b2b182
--- /dev/null
+++ b/core/services/backends_test.go
@@ -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"))
+ })
+})
diff --git a/core/services/operation.go b/core/services/operation.go
index 0b79f0dcb..4ff1f71c1 100644
--- a/core/services/operation.go
+++ b/core/services/operation.go
@@ -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
}
}
}
diff --git a/core/startup/backend_preload.go b/core/startup/backend_preload.go
deleted file mode 100644
index 835a0bc00..000000000
--- a/core/startup/backend_preload.go
+++ /dev/null
@@ -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
-}