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 -}