mirror of
https://github.com/mudler/LocalAI.git
synced 2025-12-31 06:29:55 -06:00
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:
committed by
GitHub
parent
7790a24682
commit
e1874cdb54
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]{
|
||||
|
||||
210
core/http/routes/ui_api_backends_test.go
Normal file
210
core/http/routes/ui_api_backends_test.go
Normal 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{}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
193
core/services/backends_test.go
Normal file
193
core/services/backends_test.go
Normal 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"))
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user