mirror of
https://github.com/mudler/LocalAI.git
synced 2026-05-04 14:09:34 -04:00
* feat(react-ui): add Face & Voice Recognition pages
Expose the face and voice biometrics endpoints
(/v1/face/*, /v1/voice/*) through the React UI. Each page has four
tabs driving the six endpoints per modality: Analyze (demographics
with bounding boxes / waveform segments), Compare (verify with a
match gauge and live threshold slider), Enrollment (register /
identify / forget with a top-K matches view), Embedding (raw
vector inspector with sparkline + copy).
MediaInput supports file upload plus live capture: webcam
snap-to-canvas for face, MediaRecorder -> AudioContext ->
16-bit PCM mono WAV transcode for voice (libsndfile on the
backend only handles WAV/FLAC/OGG natively).
Sidebar gets a new Biometrics section feature-gated on
face_recognition / voice_recognition; routes are wrapped in
<RequireFeature>. No new dependencies -- Font Awesome icons
picked from the Free set.
Assisted-by: Claude:Opus 4.7
* fix(localai): accept data URI prefixes with codec/charset params
Browser MediaRecorder produces data URIs like
data:audio/webm;codecs=opus;base64,...
so the pre-';base64,' section can carry multiple parameter
segments. The `^data:([^;]+);base64,` regex in pkg/utils/base64.go
and core/http/endpoints/localai/audio.go only matched exactly one
segment, so recordings straight from the React UI's live-capture
tab failed the strip and then tripped the base64 decoder on the
leading 'data:' literal, surfacing as
"invalid audio base64: illegal base64 data at input byte 4"
Widened both regexes to `^data:[^,]+?;base64,` so any number of
';param=value' segments between the mime type and ';base64,' are
tolerated. Added a regression test covering the MediaRecorder
shape.
Assisted-by: Claude:Opus 4.7
* fix(insightface): scope pack ONNX loading to known manifests
LocalAI's gallery extracts buffalo_* zips flat into the models
directory, which inevitably mixes with ONNX files from other
backends (opencv face engine, MiniFASNet antispoof, WeSpeaker
voice embedding) and older buffalo pack installs. Feeding those
foreign files into insightface's model_zoo.get_model() blows up
inside the router -- it assumes a 4-D NCHW input and indexes
`input_shape[2]` on tensors that aren't shaped like a face model,
raising IndexError mid-load and leaving the backend unusable.
The router's dispatch isn't amenable to per-file try/except alone
(first-file-wins picks det_10g.onnx from buffalo_l even when the
user asked for buffalo_sc -- alphabetical order happens to favour
the wrong pack). Instead, ship an explicit manifest of the
upstream v0.7 pack contents and scope the glob to that when the
requested pack is known. The manifest is small and stable; future
packs can be added alongside or fall through to the tolerance
loop, which also swallows any remaining IndexError / ValueError
from foreign files with a clear `[insightface] skipped` stderr
line for diagnostics.
Assisted-by: Claude:Opus 4.7
* fix(speaker-recognition): extract FBank features for rank-3 ONNX encoders
Pre-exported speaker-encoder ONNX graphs come in two shapes:
rank-2 [batch, samples] -- some 3D-Speaker exports,
take raw waveform directly.
rank-3 [batch, frames, n_mels] -- WeSpeaker and most Kaldi-
lineage encoders, expect
pre-computed Kaldi FBank.
OnnxDirectEngine unconditionally fed `audio.reshape(1, -1)` --
correct for rank-2, IndexError-on-input_shape[3] on rank-3, which
surfaced to the UI as
"Invalid rank for input: feats Got: 2 Expected: 3"
Detect the input rank at session init and run Kaldi FBank
(80-dim, 25ms/10ms frames, dither=0.0, per-utterance CMN) before
the forward pass when rank>=3. All knobs are configurable via
backend options for encoders that deviate from defaults.
torchaudio.compliance.kaldi is already in the backend's
requirements (SpeechBrain pulls torchaudio in), so no new
dependency.
Assisted-by: Claude:Opus 4.7
* fix(biometrics): isolate face and voice vector stores
Face (ArcFace, 512-D) and voice (ECAPA-TDNN 192-D / WeSpeaker
256-D) biometric embeddings were colliding inside a single
in-memory local-store instance. Enrolling one after the other
failed with
"Try to add key with length N when existing length is M"
because local-store correctly refuses to mix dimensions in one
keyspace.
The registries were constructed with `storeName=""`, which in
StoreBackend() is just a WithModel() call. But ModelLoader's
cache is keyed on `modelID`, not `model` -- so both registries
collapsed to the same `modelID=""` slot and reused the same
backend process despite looking isolated on paper.
Three complementary fixes:
1. application.go -- give each registry a distinct default
namespace ("localai-face-biometrics" /
"localai-voice-biometrics"). The comment claimed
isolation, now it's actually enforced.
2. stores.go -- pass the storeName as both WithModelID and
WithModel so the ModelLoader cache key separates
namespaces and the loader spawns distinct processes.
3. local-store/store.go -- drop the Load() `opts.Model != ""`
guard. It was there to prevent generic model-loading loops
from picking up local-store by accident, but that auto-load
path is being retired; the guard now just blocks legitimate
namespace isolation. opts.Model is treated as a tag; the
per-tuple process isolation upstream handles discrimination.
Assisted-by: Claude:Opus 4.7
* fix(gallery): stale-file cleanup and upgrade-tmp directory safety
Two related robustness fixes for backend install/upgrade:
pkg/downloader/uri.go
OCI downloads passed through
if filepath.Ext(filePath) != "" ...
filePath = filepath.Dir(filePath)
which was intended to redirect file-shaped download targets
into their parent directory for OCI extraction. The heuristic
misfires on directory-shaped paths with a dot-suffix --
gallery.UpgradeBackend uses
tmpPath = "<backendsPath>/<name>.upgrade-tmp"
and Go's filepath.Ext treats ".upgrade-tmp" as an extension.
The rewrite landed the extraction at "<backendsPath>/", which
then **overwrote the real install** (backends/<name>/) with a
flat-layout file and left a stray run.sh at the top level. The
tmp dir itself stayed empty, so the validation step that
checked "<tmpPath>/run.sh" predictably failed with
"upgrade validation failed: run.sh not found in new backend"
Every manual upgrade silently corrupted the backends tree this
way. Guard the rewrite behind "target isn't already an existing
directory" -- InstallBackend / UpgradeBackend both pre-create
the target as a directory, so they get the correct behaviour;
existing file-path callers with a genuine dot-extension still
get the parent redirect.
core/gallery/backends.go
InstallBackend's MkdirAll returned ENOTDIR when something at
the target path was already a file (legacy dev builds dropped
golang backend binaries directly at `<backendsPath>/<name>`
instead of nesting them under their own subdir). That
permanently blocked reinstall and upgrade for anyone carrying
that state, since every retry hit the same error. Detect a
pre-existing non-directory, warn, and remove it before the
MkdirAll so the fresh install can write the correct nested
layout with metadata.json + run.sh.
Assisted-by: Claude:Opus 4.7
* fix(galleryop): refresh upgrade cache after backend ops
UpgradeChecker caches the last upgrade-check result and only
refreshes on the 6-hour tick or after an auto-upgrade cycle.
Manual upgrades (POST /api/backends/upgrade/:name) go through
the async galleryop worker, which completes the upgrade
correctly but never tells UpgradeChecker to re-check -- so
/api/backends/upgrades continued to list a just-upgraded backend
as upgradeable, indistinguishable from a failed upgrade, for up
to six hours.
Add an optional `OnBackendOpCompleted func()` hook on
GalleryService that fires after every successful install /
upgrade / delete on the backend channel (async, so a slow
callback doesn't stall the queue). startup.go wires it to
UpgradeChecker.TriggerCheck after both services exist. Result:
the upgrade banner clears within milliseconds of the worker
finishing.
Assisted-by: Claude:Opus 4.7
* build: prepend GOPATH/bin to PATH for protogen-go
install-go-tools runs `go install` for protoc-gen-go and
protoc-gen-go-grpc, which writes them into `go env GOPATH`/bin.
That directory isn't on every dev's PATH, and protoc resolves
its code-gen plugins via PATH, so the immediately-following
protoc invocation fails with
"protoc-gen-go: program not found"
which in turn blocks `make build` and any
`make backends/%` target that depends on build.
Prepend `go env GOPATH`/bin to PATH for the protoc invocation
so the freshly-installed plugins are found without requiring a
shell-profile change.
Assisted-by: Claude:Opus 4.7
* refactor(ui-api): non-blocking backend upgrade handler with opcache
POST /api/backends/upgrade/:name used to send the ManagementOp
directly onto the unbuffered BackendGalleryChannel, which blocked
the HTTP request whenever the galleryop worker was busy with a
prior operation. The op also didn't show up in /api/operations,
so the Backends UI couldn't reflect upgrade progress on the
affected row.
Register the op in opcache immediately, wrap it in a cancellable
context, store the cancellation function on the GalleryService,
and push onto the channel from a goroutine so the handler
returns right away. Response gains a `jobID` field and a
`message` string so clients have a consistent handle regardless
of whether the op is queued or running.
Pairs with the OnBackendOpCompleted hook added in the galleryop
commit — together the UI sees the upgrade start, watches
progress via /api/operations, and drops the "upgradeable" flag
the moment the worker finishes.
Assisted-by: Claude:Opus 4.7
1480 lines
44 KiB
Go
1480 lines
44 KiB
Go
package routes
|
|
|
|
import "os"
|
|
|
|
import (
|
|
"cmp"
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/labstack/echo/v4"
|
|
"github.com/mudler/LocalAI/core/application"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/gallery"
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
"github.com/mudler/LocalAI/core/http/endpoints/localai"
|
|
"github.com/mudler/LocalAI/core/p2p"
|
|
"github.com/mudler/LocalAI/core/services/galleryop"
|
|
"github.com/mudler/LocalAI/pkg/model"
|
|
"github.com/mudler/LocalAI/pkg/vram"
|
|
"github.com/mudler/LocalAI/pkg/xsysinfo"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
const (
|
|
nameSortFieldName = "name"
|
|
repositorySortFieldName = "repository"
|
|
licenseSortFieldName = "license"
|
|
statusSortFieldName = "status"
|
|
ascSortOrder = "asc"
|
|
)
|
|
|
|
// getDirectorySize calculates the total size of files in a directory
|
|
func getDirectorySize(path string) (int64, error) {
|
|
var totalSize int64
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
for _, entry := range entries {
|
|
info, err := entry.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if !info.IsDir() {
|
|
totalSize += info.Size()
|
|
}
|
|
}
|
|
return totalSize, nil
|
|
}
|
|
|
|
// RegisterUIAPIRoutes registers JSON API routes for the web UI
|
|
func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model.ModelLoader, appConfig *config.ApplicationConfig, galleryService *galleryop.GalleryService, opcache *galleryop.OpCache, applicationInstance *application.Application, adminMiddleware echo.MiddlewareFunc) {
|
|
|
|
// Operations API - Get all current operations (models + backends)
|
|
app.GET("/api/operations", func(c echo.Context) error {
|
|
processingData, taskTypes := opcache.GetStatus()
|
|
|
|
operations := []map[string]any{}
|
|
for galleryID, jobID := range processingData {
|
|
taskType := "installation"
|
|
if tt, ok := taskTypes[galleryID]; ok {
|
|
taskType = tt
|
|
}
|
|
|
|
status := galleryService.GetStatus(jobID)
|
|
progress := 0
|
|
isDeletion := false
|
|
isQueued := false
|
|
isCancelled := false
|
|
isCancellable := false
|
|
message := ""
|
|
|
|
if status != nil {
|
|
// Skip successfully completed operations
|
|
if status.Processed && !status.Cancelled && status.Error == nil {
|
|
continue
|
|
}
|
|
// Skip cancelled operations that are processed (they're done, no need to show)
|
|
if status.Processed && status.Cancelled {
|
|
continue
|
|
}
|
|
|
|
progress = int(status.Progress)
|
|
isDeletion = status.Deletion
|
|
isCancelled = status.Cancelled
|
|
isCancellable = status.Cancellable
|
|
message = status.Message
|
|
if isDeletion {
|
|
taskType = "deletion"
|
|
}
|
|
if isCancelled {
|
|
taskType = "cancelled"
|
|
}
|
|
} else {
|
|
// Job is queued but hasn't started
|
|
isQueued = true
|
|
isCancellable = true
|
|
message = "Operation queued"
|
|
}
|
|
|
|
// Determine if it's a model or backend
|
|
// 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
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract display name (remove repo prefix if exists)
|
|
displayName := galleryID
|
|
if strings.Contains(galleryID, "@") {
|
|
parts := strings.Split(galleryID, "@")
|
|
if len(parts) > 1 {
|
|
displayName = parts[1]
|
|
}
|
|
}
|
|
|
|
opData := map[string]any{
|
|
"id": galleryID,
|
|
"name": displayName,
|
|
"fullName": galleryID,
|
|
"jobID": jobID,
|
|
"progress": progress,
|
|
"taskType": taskType,
|
|
"isDeletion": isDeletion,
|
|
"isBackend": isBackend,
|
|
"isQueued": isQueued,
|
|
"isCancelled": isCancelled,
|
|
"cancellable": isCancellable,
|
|
"message": message,
|
|
}
|
|
if status != nil && status.Error != nil {
|
|
opData["error"] = status.Error.Error()
|
|
}
|
|
operations = append(operations, opData)
|
|
}
|
|
|
|
// Append active file staging operations (distributed mode only)
|
|
if d := applicationInstance.Distributed(); d != nil && d.Router != nil {
|
|
for modelID, status := range d.Router.StagingTracker().GetAll() {
|
|
operations = append(operations, map[string]any{
|
|
"id": "staging:" + modelID,
|
|
"name": modelID,
|
|
"fullName": modelID,
|
|
"jobID": "staging:" + modelID,
|
|
"progress": int(status.Progress),
|
|
"taskType": "staging",
|
|
"isDeletion": false,
|
|
"isBackend": false,
|
|
"isQueued": false,
|
|
"isCancelled": false,
|
|
"cancellable": false,
|
|
"message": status.Message,
|
|
"nodeName": status.NodeName,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Sort operations by progress (ascending), then by ID for stable display order
|
|
slices.SortFunc(operations, func(a, b map[string]any) int {
|
|
progressA := a["progress"].(int)
|
|
progressB := b["progress"].(int)
|
|
|
|
// Primary sort by progress
|
|
if progressA != progressB {
|
|
return cmp.Compare(progressA, progressB)
|
|
}
|
|
|
|
// Secondary sort by ID for stability when progress is the same
|
|
return cmp.Compare(a["id"].(string), b["id"].(string))
|
|
})
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"operations": operations,
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
// Cancel operation endpoint (admin only)
|
|
app.POST("/api/operations/:jobID/cancel", func(c echo.Context) error {
|
|
jobID := c.Param("jobID")
|
|
xlog.Debug("API request to cancel operation", "jobID", jobID)
|
|
|
|
err := galleryService.CancelOperation(jobID)
|
|
if err != nil {
|
|
xlog.Error("Failed to cancel operation", "error", err, "jobID", jobID)
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
// Clean up opcache for cancelled operation
|
|
opcache.DeleteUUID(jobID)
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"success": true,
|
|
"message": "Operation cancelled",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
// Dismiss a failed operation (acknowledge the error and remove it from the list)
|
|
app.POST("/api/operations/:jobID/dismiss", func(c echo.Context) error {
|
|
jobID := c.Param("jobID")
|
|
xlog.Debug("API request to dismiss operation", "jobID", jobID)
|
|
|
|
// Remove the operation from the opcache so it no longer appears
|
|
opcache.DeleteUUID(jobID)
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"success": true,
|
|
"message": "Operation dismissed",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
// Model Gallery APIs (admin only)
|
|
app.GET("/api/models", func(c echo.Context) error {
|
|
term := c.QueryParam("term")
|
|
tag := c.QueryParam("tag")
|
|
page := c.QueryParam("page")
|
|
if page == "" {
|
|
page = "1"
|
|
}
|
|
items := c.QueryParam("items")
|
|
if items == "" {
|
|
items = "9"
|
|
}
|
|
|
|
models, err := gallery.AvailableGalleryModels(appConfig.Galleries, appConfig.SystemState)
|
|
if err != nil {
|
|
xlog.Error("could not list models from galleries", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
// Get all available tags
|
|
allTags := map[string]struct{}{}
|
|
tags := []string{}
|
|
for _, m := range models {
|
|
for _, t := range m.Tags {
|
|
allTags[t] = struct{}{}
|
|
}
|
|
}
|
|
for t := range allTags {
|
|
tags = append(tags, t)
|
|
}
|
|
slices.Sort(tags)
|
|
|
|
// Get all available backends (before filtering so dropdown always shows all)
|
|
allBackendsMap := map[string]struct{}{}
|
|
for _, m := range models {
|
|
if b := m.Backend; b != "" {
|
|
allBackendsMap[b] = struct{}{}
|
|
}
|
|
}
|
|
backendNames := make([]string, 0, len(allBackendsMap))
|
|
for b := range allBackendsMap {
|
|
backendNames = append(backendNames, b)
|
|
}
|
|
slices.Sort(backendNames)
|
|
|
|
if tag != "" {
|
|
models = gallery.GalleryElements[*gallery.GalleryModel](models).FilterByTag(tag)
|
|
}
|
|
if term != "" {
|
|
models = gallery.GalleryElements[*gallery.GalleryModel](models).Search(term)
|
|
}
|
|
|
|
// Filter by backend if requested
|
|
backendFilter := c.QueryParam("backend")
|
|
if backendFilter != "" {
|
|
var filtered gallery.GalleryElements[*gallery.GalleryModel]
|
|
for _, m := range models {
|
|
if m.Backend == backendFilter {
|
|
filtered = append(filtered, m)
|
|
}
|
|
}
|
|
models = filtered
|
|
}
|
|
|
|
// Get model statuses
|
|
processingModelsData, taskTypes := opcache.GetStatus()
|
|
|
|
// Apply sorting if requested
|
|
sortBy := c.QueryParam("sort")
|
|
sortOrder := c.QueryParam("order")
|
|
if sortOrder == "" {
|
|
sortOrder = ascSortOrder
|
|
}
|
|
|
|
switch sortBy {
|
|
case nameSortFieldName:
|
|
models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByName(sortOrder)
|
|
case repositorySortFieldName:
|
|
models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByRepository(sortOrder)
|
|
case licenseSortFieldName:
|
|
models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByLicense(sortOrder)
|
|
case statusSortFieldName:
|
|
models = gallery.GalleryElements[*gallery.GalleryModel](models).SortByInstalled(sortOrder)
|
|
}
|
|
|
|
pageNum, err := strconv.Atoi(page)
|
|
if err != nil || pageNum < 1 {
|
|
pageNum = 1
|
|
}
|
|
|
|
itemsNum, err := strconv.Atoi(items)
|
|
if err != nil || itemsNum < 1 {
|
|
itemsNum = 9
|
|
}
|
|
|
|
totalPages := int(math.Ceil(float64(len(models)) / float64(itemsNum)))
|
|
totalModels := len(models)
|
|
|
|
if pageNum > 0 {
|
|
models = models.Paginate(pageNum, itemsNum)
|
|
}
|
|
|
|
// Convert models to JSON-friendly format and deduplicate by ID
|
|
modelsJSON := make([]map[string]any, 0, len(models))
|
|
seenIDs := make(map[string]bool)
|
|
|
|
weightExts := map[string]bool{".gguf": true, ".safetensors": true, ".bin": true, ".pt": true}
|
|
extractHFRepo := func(overrides map[string]any, urls []string) string {
|
|
// Try overrides.parameters.model first
|
|
if overrides != nil {
|
|
if params, ok := overrides["parameters"].(map[string]any); ok {
|
|
if modelRef, ok := params["model"].(string); ok {
|
|
if repoID, ok := vram.ExtractHFRepoID(modelRef); ok {
|
|
return repoID
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Fall back to the first HuggingFace URL in the metadata urls list
|
|
for _, u := range urls {
|
|
if repoID, ok := vram.ExtractHFRepoID(u); ok {
|
|
return repoID
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
hasWeightFiles := func(files []gallery.File) bool {
|
|
for _, f := range files {
|
|
ext := strings.ToLower(path.Ext(path.Base(f.URI)))
|
|
if weightExts[ext] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
const hfEstimateTimeout = 10 * time.Second
|
|
const estimateConcurrency = 3
|
|
sem := make(chan struct{}, estimateConcurrency)
|
|
var wg sync.WaitGroup
|
|
|
|
for _, m := range models {
|
|
modelID := m.ID()
|
|
|
|
// Skip duplicate IDs to prevent Alpine.js x-for errors
|
|
if seenIDs[modelID] {
|
|
xlog.Debug("Skipping duplicate model ID", "modelID", modelID)
|
|
continue
|
|
}
|
|
seenIDs[modelID] = true
|
|
|
|
currentlyProcessing := opcache.Exists(modelID)
|
|
jobID := ""
|
|
isDeletionOp := false
|
|
if currentlyProcessing {
|
|
jobID = opcache.Get(modelID)
|
|
status := galleryService.GetStatus(jobID)
|
|
if status != nil && status.Deletion {
|
|
isDeletionOp = true
|
|
}
|
|
}
|
|
|
|
_, trustRemoteCodeExists := m.Overrides["trust_remote_code"]
|
|
|
|
obj := map[string]any{
|
|
"id": modelID,
|
|
"name": m.Name,
|
|
"description": m.Description,
|
|
"icon": m.Icon,
|
|
"license": m.License,
|
|
"urls": m.URLs,
|
|
"tags": m.Tags,
|
|
"gallery": m.Gallery.Name,
|
|
"installed": m.Installed,
|
|
"processing": currentlyProcessing,
|
|
"jobID": jobID,
|
|
"isDeletion": isDeletionOp,
|
|
"trustRemoteCode": trustRemoteCodeExists,
|
|
"additionalFiles": m.AdditionalFiles,
|
|
"backend": m.Backend,
|
|
}
|
|
|
|
// Build EstimateModel input from available metadata
|
|
var estimateInput vram.ModelEstimateInput
|
|
estimateInput.Options = vram.EstimateOptions{ContextLength: 8192}
|
|
estimateInput.Size = m.Size
|
|
if hfRepoID := extractHFRepo(m.Overrides, m.URLs); hfRepoID != "" {
|
|
estimateInput.HFRepo = hfRepoID
|
|
}
|
|
|
|
if hasWeightFiles(m.AdditionalFiles) {
|
|
files := make([]gallery.File, len(m.AdditionalFiles))
|
|
copy(files, m.AdditionalFiles)
|
|
for _, f := range files {
|
|
ext := strings.ToLower(path.Ext(path.Base(f.URI)))
|
|
if weightExts[ext] {
|
|
estimateInput.Files = append(estimateInput.Files, vram.FileInput{URI: f.URI, Size: 0})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Run estimation (async for file-based and HF repo, sync for size string only)
|
|
needsAsync := len(estimateInput.Files) > 0 || estimateInput.HFRepo != ""
|
|
if needsAsync {
|
|
input := estimateInput
|
|
wg.Go(func() {
|
|
sem <- struct{}{}
|
|
defer func() { <-sem }()
|
|
ctx, cancel := context.WithTimeout(context.Background(), hfEstimateTimeout)
|
|
defer cancel()
|
|
result, err := vram.EstimateModel(ctx, input)
|
|
if err == nil {
|
|
if result.SizeBytes > 0 {
|
|
obj["estimated_size_bytes"] = result.SizeBytes
|
|
obj["estimated_size_display"] = result.SizeDisplay
|
|
}
|
|
if result.VRAMBytes > 0 {
|
|
obj["estimated_vram_bytes"] = result.VRAMBytes
|
|
obj["estimated_vram_display"] = result.VRAMDisplay
|
|
}
|
|
}
|
|
})
|
|
} else if estimateInput.Size != "" {
|
|
result, _ := vram.EstimateModel(context.Background(), estimateInput)
|
|
if result.SizeBytes > 0 {
|
|
obj["estimated_size_bytes"] = result.SizeBytes
|
|
obj["estimated_size_display"] = result.SizeDisplay
|
|
}
|
|
if result.VRAMBytes > 0 {
|
|
obj["estimated_vram_bytes"] = result.VRAMBytes
|
|
obj["estimated_vram_display"] = result.VRAMDisplay
|
|
}
|
|
}
|
|
|
|
modelsJSON = append(modelsJSON, obj)
|
|
}
|
|
|
|
wg.Wait()
|
|
|
|
prevPage := pageNum - 1
|
|
nextPage := pageNum + 1
|
|
if prevPage < 1 {
|
|
prevPage = 1
|
|
}
|
|
if nextPage > totalPages {
|
|
nextPage = totalPages
|
|
}
|
|
|
|
// Calculate installed models count (models with configs + models without configs)
|
|
modelConfigs := cl.GetAllModelsConfigs()
|
|
modelsWithoutConfig, _ := galleryop.ListModels(cl, ml, config.NoFilterFn, galleryop.LOOSE_ONLY)
|
|
installedModelsCount := len(modelConfigs) + len(modelsWithoutConfig)
|
|
|
|
ramInfo, _ := xsysinfo.GetSystemRAMInfo()
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"models": modelsJSON,
|
|
"repositories": appConfig.Galleries,
|
|
"allTags": tags,
|
|
"allBackends": backendNames,
|
|
"processingModels": processingModelsData,
|
|
"taskTypes": taskTypes,
|
|
"availableModels": totalModels,
|
|
"installedModels": installedModelsCount,
|
|
"ramTotal": ramInfo.Total,
|
|
"ramUsed": ramInfo.Used,
|
|
"ramUsagePercent": ramInfo.UsagePercent,
|
|
"currentPage": pageNum,
|
|
"totalPages": totalPages,
|
|
"prevPage": prevPage,
|
|
"nextPage": nextPage,
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
// Returns installed models with their capability flags for UI filtering
|
|
app.GET("/api/models/capabilities", func(c echo.Context) error {
|
|
modelConfigs := cl.GetAllModelsConfigs()
|
|
modelsWithoutConfig, _ := galleryop.ListModels(cl, ml, config.NoFilterFn, galleryop.LOOSE_ONLY)
|
|
|
|
type loadedOn struct {
|
|
NodeID string `json:"node_id"`
|
|
NodeName string `json:"node_name"`
|
|
State string `json:"state"`
|
|
NodeStatus string `json:"node_status"`
|
|
}
|
|
type modelCapability struct {
|
|
ID string `json:"id"`
|
|
Capabilities []string `json:"capabilities"`
|
|
Backend string `json:"backend"`
|
|
Disabled bool `json:"disabled"`
|
|
Pinned bool `json:"pinned"`
|
|
// LoadedOn is populated only when the node registry is active
|
|
// (distributed mode). Lets the UI show "loaded on worker-1" without
|
|
// the operator having to expand every node manually. An empty slice
|
|
// with nil reports "no loaded replicas" vs. nil reports "not in
|
|
// cluster mode" — the frontend treats both as "no distribution info".
|
|
LoadedOn []loadedOn `json:"loaded_on,omitempty"`
|
|
// Source="registry-only" marks models adopted from the cluster that
|
|
// have no local config yet (ghosts that the reconciler discovered).
|
|
Source string `json:"source,omitempty"`
|
|
}
|
|
|
|
// Join with the node registry when we have one (distributed mode). A
|
|
// single registry fetch + map join beats per-model queries for the
|
|
// 100-model case.
|
|
var loadedByModel map[string][]loadedOn
|
|
if ds := applicationInstance.Distributed(); ds != nil && ds.Registry != nil {
|
|
nodeModels, err := ds.Registry.ListAllLoadedModels(c.Request().Context())
|
|
if err == nil {
|
|
allNodes, _ := ds.Registry.List(c.Request().Context())
|
|
nameByID := make(map[string]string, len(allNodes))
|
|
statusByID := make(map[string]string, len(allNodes))
|
|
for _, n := range allNodes {
|
|
nameByID[n.ID] = n.Name
|
|
statusByID[n.ID] = n.Status
|
|
}
|
|
loadedByModel = make(map[string][]loadedOn)
|
|
for _, nm := range nodeModels {
|
|
loadedByModel[nm.ModelName] = append(loadedByModel[nm.ModelName], loadedOn{
|
|
NodeID: nm.NodeID,
|
|
NodeName: nameByID[nm.NodeID],
|
|
State: nm.State,
|
|
NodeStatus: statusByID[nm.NodeID],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
result := make([]modelCapability, 0, len(modelConfigs)+len(modelsWithoutConfig))
|
|
seen := make(map[string]bool, len(modelConfigs)+len(modelsWithoutConfig))
|
|
for _, cfg := range modelConfigs {
|
|
seen[cfg.Name] = true
|
|
result = append(result, modelCapability{
|
|
ID: cfg.Name,
|
|
Capabilities: cfg.KnownUsecaseStrings,
|
|
Backend: cfg.Backend,
|
|
Disabled: cfg.IsDisabled(),
|
|
Pinned: cfg.IsPinned(),
|
|
LoadedOn: loadedByModel[cfg.Name],
|
|
})
|
|
}
|
|
for _, name := range modelsWithoutConfig {
|
|
seen[name] = true
|
|
result = append(result, modelCapability{
|
|
ID: name,
|
|
Capabilities: []string{},
|
|
LoadedOn: loadedByModel[name],
|
|
})
|
|
}
|
|
// Emit entries for cluster models that have no local config — these
|
|
// are the actual ghosts. Without this the operator would have no way
|
|
// to see a model the cluster is running if its config file wasn't
|
|
// synced to this frontend's filesystem.
|
|
for name, loc := range loadedByModel {
|
|
if seen[name] {
|
|
continue
|
|
}
|
|
result = append(result, modelCapability{
|
|
ID: name,
|
|
Capabilities: []string{},
|
|
LoadedOn: loc,
|
|
Source: "registry-only",
|
|
})
|
|
}
|
|
|
|
// Filter by user's model allowlist if auth is enabled
|
|
if authDB := applicationInstance.AuthDB(); authDB != nil {
|
|
if user := auth.GetUser(c); user != nil && user.Role != auth.RoleAdmin {
|
|
perm, err := auth.GetCachedUserPermissions(c, authDB, user.ID)
|
|
if err == nil && perm.AllowedModels.Enabled {
|
|
allowed := map[string]bool{}
|
|
for _, m := range perm.AllowedModels.Models {
|
|
allowed[m] = true
|
|
}
|
|
filtered := make([]modelCapability, 0, len(result))
|
|
for _, mc := range result {
|
|
if allowed[mc.ID] {
|
|
filtered = append(filtered, mc)
|
|
}
|
|
}
|
|
result = filtered
|
|
}
|
|
}
|
|
}
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"data": result,
|
|
})
|
|
})
|
|
|
|
app.POST("/api/models/install/:id", func(c echo.Context) error {
|
|
galleryID := c.Param("id")
|
|
// URL decode the gallery ID (e.g., "localai%40model" -> "localai@model")
|
|
galleryID, err := url.QueryUnescape(galleryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "invalid model ID",
|
|
})
|
|
}
|
|
xlog.Debug("API job submitted to install", "galleryID", galleryID)
|
|
|
|
id, err := uuid.NewUUID()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
uid := id.String()
|
|
opcache.Set(galleryID, uid)
|
|
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
op := galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
|
|
ID: uid,
|
|
GalleryElementName: galleryID,
|
|
Galleries: appConfig.Galleries,
|
|
BackendGalleries: appConfig.BackendGalleries,
|
|
Context: ctx,
|
|
CancelFunc: cancelFunc,
|
|
}
|
|
// Store cancellation function immediately so queued operations can be cancelled
|
|
galleryService.StoreCancellation(uid, cancelFunc)
|
|
go func() {
|
|
galleryService.ModelGalleryChannel <- op
|
|
}()
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"jobID": uid,
|
|
"message": "Installation started",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
app.POST("/api/models/delete/:id", func(c echo.Context) error {
|
|
galleryID := c.Param("id")
|
|
// URL decode the gallery ID
|
|
galleryID, err := url.QueryUnescape(galleryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "invalid model ID",
|
|
})
|
|
}
|
|
xlog.Debug("API job submitted to delete", "galleryID", galleryID)
|
|
|
|
var galleryName = galleryID
|
|
if strings.Contains(galleryID, "@") {
|
|
galleryName = strings.Split(galleryID, "@")[1]
|
|
}
|
|
|
|
id, err := uuid.NewUUID()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
uid := id.String()
|
|
|
|
opcache.Set(galleryID, uid)
|
|
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
op := galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
|
|
ID: uid,
|
|
Delete: true,
|
|
GalleryElementName: galleryName,
|
|
Galleries: appConfig.Galleries,
|
|
BackendGalleries: appConfig.BackendGalleries,
|
|
Context: ctx,
|
|
CancelFunc: cancelFunc,
|
|
}
|
|
// Store cancellation function immediately so queued operations can be cancelled
|
|
galleryService.StoreCancellation(uid, cancelFunc)
|
|
go func() {
|
|
galleryService.ModelGalleryChannel <- op
|
|
cl.RemoveModelConfig(galleryName)
|
|
}()
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"jobID": uid,
|
|
"message": "Deletion started",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
app.POST("/api/models/config/:id", func(c echo.Context) error {
|
|
galleryID := c.Param("id")
|
|
// URL decode the gallery ID
|
|
galleryID, err := url.QueryUnescape(galleryID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "invalid model ID",
|
|
})
|
|
}
|
|
xlog.Debug("API job submitted to get config", "galleryID", galleryID)
|
|
|
|
models, err := gallery.AvailableGalleryModels(appConfig.Galleries, appConfig.SystemState)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
model := gallery.FindGalleryElement(models, galleryID)
|
|
if model == nil {
|
|
return c.JSON(http.StatusNotFound, map[string]any{
|
|
"error": "model not found",
|
|
})
|
|
}
|
|
|
|
config, err := gallery.GetGalleryConfigFromURL[gallery.ModelConfig](model.URL, appConfig.SystemState.Model.ModelsPath)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
_, err = gallery.InstallModel(context.Background(), appConfig.SystemState, model.Name, &config, model.Overrides, nil, false)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"message": "Configuration file saved",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
// Get installed model config as JSON (used by frontend for MCP detection, etc.)
|
|
app.GET("/api/models/config-json/:name", func(c echo.Context) error {
|
|
modelName := c.Param("name")
|
|
if modelName == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "model name is required",
|
|
})
|
|
}
|
|
|
|
modelConfig, exists := cl.GetModelConfig(modelName)
|
|
if !exists {
|
|
return c.JSON(http.StatusNotFound, map[string]any{
|
|
"error": "model configuration not found",
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, modelConfig)
|
|
}, adminMiddleware)
|
|
|
|
// Config metadata API - returns field metadata for all ~170 config fields
|
|
app.GET("/api/models/config-metadata", localai.ConfigMetadataEndpoint(), adminMiddleware)
|
|
|
|
// Autocomplete providers for config fields (dynamic values only)
|
|
app.GET("/api/models/config-metadata/autocomplete/:provider", localai.AutocompleteEndpoint(cl, ml, appConfig), adminMiddleware)
|
|
|
|
// PATCH config endpoint - partial update using nested JSON merge
|
|
app.PATCH("/api/models/config-json/:name", localai.PatchConfigEndpoint(cl, ml, appConfig), adminMiddleware)
|
|
|
|
// VRAM estimation endpoint
|
|
app.POST("/api/models/vram-estimate", localai.VRAMEstimateEndpoint(cl, appConfig), adminMiddleware)
|
|
|
|
// Get installed model YAML config for the React model editor
|
|
app.GET("/api/models/edit/:name", func(c echo.Context) error {
|
|
modelName := c.Param("name")
|
|
if decoded, err := url.PathUnescape(modelName); err == nil {
|
|
modelName = decoded
|
|
}
|
|
if modelName == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "model name is required",
|
|
})
|
|
}
|
|
|
|
modelConfig, exists := cl.GetModelConfig(modelName)
|
|
if !exists {
|
|
return c.JSON(http.StatusNotFound, map[string]any{
|
|
"error": "model configuration not found",
|
|
})
|
|
}
|
|
|
|
modelConfigFile := modelConfig.GetModelConfigFile()
|
|
if modelConfigFile == "" {
|
|
return c.JSON(http.StatusNotFound, map[string]any{
|
|
"error": "model configuration file not found",
|
|
})
|
|
}
|
|
|
|
configData, err := os.ReadFile(modelConfigFile)
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": "failed to read configuration file: " + err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
"config": string(configData),
|
|
"name": modelName,
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
app.GET("/api/models/job/:uid", func(c echo.Context) error {
|
|
jobUID := c.Param("uid")
|
|
|
|
status := galleryService.GetStatus(jobUID)
|
|
if status == nil {
|
|
// Job is queued but hasn't started processing yet
|
|
return c.JSON(200, map[string]any{
|
|
"progress": 0,
|
|
"message": "Operation queued",
|
|
"galleryElementName": "",
|
|
"processed": false,
|
|
"deletion": false,
|
|
"queued": true,
|
|
})
|
|
}
|
|
|
|
response := map[string]any{
|
|
"progress": status.Progress,
|
|
"message": status.Message,
|
|
"galleryElementName": status.GalleryElementName,
|
|
"processed": status.Processed,
|
|
"deletion": status.Deletion,
|
|
"queued": false,
|
|
}
|
|
|
|
if status.Error != nil {
|
|
response["error"] = status.Error.Error()
|
|
}
|
|
|
|
if status.Progress == 100 && status.Processed && status.Message == "completed" {
|
|
opcache.DeleteUUID(jobUID)
|
|
response["completed"] = true
|
|
}
|
|
|
|
return c.JSON(200, response)
|
|
}, adminMiddleware)
|
|
|
|
// Backend Gallery APIs
|
|
app.GET("/api/backends", func(c echo.Context) error {
|
|
term := c.QueryParam("term")
|
|
tag := c.QueryParam("tag")
|
|
page := c.QueryParam("page")
|
|
if page == "" {
|
|
page = "1"
|
|
}
|
|
items := c.QueryParam("items")
|
|
if items == "" {
|
|
items = "9"
|
|
}
|
|
|
|
backends, err := gallery.AvailableBackendsUnfiltered(appConfig.BackendGalleries, appConfig.SystemState)
|
|
if err != nil {
|
|
xlog.Error("could not list backends from galleries", "error", err)
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
// Collect concrete backend names that are referenced by any meta backend's
|
|
// CapabilitiesMap. These are the per-capability variants the UI hides by
|
|
// default behind "Show all" (the meta backend is the preferred entry).
|
|
aliasedByMeta := make(map[string]bool)
|
|
for _, b := range backends {
|
|
if !b.IsMeta() {
|
|
continue
|
|
}
|
|
for _, concreteName := range b.CapabilitiesMap {
|
|
aliasedByMeta[concreteName] = true
|
|
}
|
|
}
|
|
|
|
// Use the BackendManager's list to determine installed status.
|
|
// In standalone mode this checks the local filesystem; in distributed
|
|
// mode it aggregates from all healthy worker nodes.
|
|
installedBackends, listErr := galleryService.ListBackends()
|
|
if listErr == nil {
|
|
for i, b := range backends {
|
|
if installedBackends.Exists(b.GetName()) {
|
|
backends[i].Installed = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get all available tags
|
|
allTags := map[string]struct{}{}
|
|
tags := []string{}
|
|
for _, b := range backends {
|
|
for _, t := range b.Tags {
|
|
allTags[t] = struct{}{}
|
|
}
|
|
}
|
|
for t := range allTags {
|
|
tags = append(tags, t)
|
|
}
|
|
slices.Sort(tags)
|
|
|
|
if tag != "" {
|
|
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).FilterByTag(tag)
|
|
}
|
|
if term != "" {
|
|
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).Search(term)
|
|
}
|
|
|
|
// Get backend statuses
|
|
processingBackendsData, taskTypes := opcache.GetStatus()
|
|
|
|
// Apply sorting if requested
|
|
sortBy := c.QueryParam("sort")
|
|
sortOrder := c.QueryParam("order")
|
|
if sortOrder == "" {
|
|
sortOrder = ascSortOrder
|
|
}
|
|
|
|
switch sortBy {
|
|
case nameSortFieldName:
|
|
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByName(sortOrder)
|
|
case repositorySortFieldName:
|
|
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByRepository(sortOrder)
|
|
case licenseSortFieldName:
|
|
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByLicense(sortOrder)
|
|
case statusSortFieldName:
|
|
backends = gallery.GalleryElements[*gallery.GalleryBackend](backends).SortByInstalled(sortOrder)
|
|
}
|
|
|
|
pageNum, err := strconv.Atoi(page)
|
|
if err != nil || pageNum < 1 {
|
|
pageNum = 1
|
|
}
|
|
|
|
itemsNum, err := strconv.Atoi(items)
|
|
if err != nil || itemsNum < 1 {
|
|
itemsNum = 9
|
|
}
|
|
|
|
totalPages := int(math.Ceil(float64(len(backends)) / float64(itemsNum)))
|
|
totalBackends := len(backends)
|
|
|
|
if pageNum > 0 {
|
|
backends = backends.Paginate(pageNum, itemsNum)
|
|
}
|
|
|
|
// Get dev suffix from SystemState for development backend detection
|
|
devSuffix := ""
|
|
if appConfig.SystemState != nil {
|
|
devSuffix = appConfig.SystemState.BackendDevSuffix
|
|
}
|
|
|
|
// Convert backends to JSON-friendly format and deduplicate by ID
|
|
backendsJSON := make([]map[string]any, 0, len(backends))
|
|
seenBackendIDs := make(map[string]bool)
|
|
|
|
for _, b := range backends {
|
|
backendID := b.ID()
|
|
|
|
// Skip duplicate IDs to prevent Alpine.js x-for errors
|
|
if seenBackendIDs[backendID] {
|
|
xlog.Debug("Skipping duplicate backend ID", "backendID", backendID)
|
|
continue
|
|
}
|
|
seenBackendIDs[backendID] = true
|
|
|
|
currentlyProcessing := opcache.Exists(backendID)
|
|
jobID := ""
|
|
isDeletionOp := false
|
|
if currentlyProcessing {
|
|
jobID = opcache.Get(backendID)
|
|
status := galleryService.GetStatus(jobID)
|
|
if status != nil && status.Deletion {
|
|
isDeletionOp = true
|
|
}
|
|
}
|
|
|
|
backendsJSON = append(backendsJSON, map[string]any{
|
|
"id": backendID,
|
|
"name": b.Name,
|
|
"description": b.Description,
|
|
"icon": b.Icon,
|
|
"license": b.License,
|
|
"urls": b.URLs,
|
|
"tags": b.Tags,
|
|
"gallery": b.Gallery.Name,
|
|
"installed": b.Installed,
|
|
"version": b.Version,
|
|
"processing": currentlyProcessing,
|
|
"jobID": jobID,
|
|
"isDeletion": isDeletionOp,
|
|
"isMeta": b.IsMeta(),
|
|
"isAlias": aliasedByMeta[b.Name],
|
|
"isDevelopment": b.IsDevelopment(devSuffix),
|
|
})
|
|
}
|
|
|
|
prevPage := pageNum - 1
|
|
nextPage := pageNum + 1
|
|
if prevPage < 1 {
|
|
prevPage = 1
|
|
}
|
|
if nextPage > totalPages {
|
|
nextPage = totalPages
|
|
}
|
|
|
|
// Calculate installed backends count (reuse the already-fetched data)
|
|
installedBackendsCount := 0
|
|
if listErr == nil {
|
|
installedBackendsCount = len(installedBackends)
|
|
} else {
|
|
// Fallback to local listing if manager listing failed
|
|
if localBackends, localErr := gallery.ListSystemBackends(appConfig.SystemState); localErr == nil {
|
|
installedBackendsCount = len(localBackends)
|
|
}
|
|
}
|
|
|
|
// Get the detected system capability
|
|
detectedCapability := ""
|
|
if appConfig.SystemState != nil {
|
|
detectedCapability = appConfig.SystemState.DetectedCapability()
|
|
}
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"backends": backendsJSON,
|
|
"repositories": appConfig.BackendGalleries,
|
|
"allTags": tags,
|
|
"processingBackends": processingBackendsData,
|
|
"taskTypes": taskTypes,
|
|
"availableBackends": totalBackends,
|
|
"installedBackends": installedBackendsCount,
|
|
"currentPage": pageNum,
|
|
"totalPages": totalPages,
|
|
"prevPage": prevPage,
|
|
"nextPage": nextPage,
|
|
"systemCapability": detectedCapability,
|
|
"preferDevelopmentBackends": appConfig.PreferDevelopmentBackends,
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
app.POST("/api/backends/install/:id", func(c echo.Context) error {
|
|
backendID := c.Param("id")
|
|
// URL decode the backend ID
|
|
backendID, err := url.QueryUnescape(backendID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "invalid backend ID",
|
|
})
|
|
}
|
|
xlog.Debug("API job submitted to install backend", "backendID", backendID)
|
|
|
|
id, err := uuid.NewUUID()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
uid := id.String()
|
|
opcache.SetBackend(backendID, uid)
|
|
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
op := galleryop.ManagementOp[gallery.GalleryBackend, any]{
|
|
ID: uid,
|
|
GalleryElementName: backendID,
|
|
Galleries: appConfig.BackendGalleries,
|
|
Context: ctx,
|
|
CancelFunc: cancelFunc,
|
|
}
|
|
// 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]any{
|
|
"jobID": uid,
|
|
"message": "Backend installation started",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
// 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]any{
|
|
"error": "invalid request body",
|
|
})
|
|
}
|
|
|
|
// Validate required fields
|
|
if req.URI == "" {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "uri is required",
|
|
})
|
|
}
|
|
|
|
xlog.Debug("API job submitted to install external backend", "uri", req.URI, "name", req.Name, "alias", req.Alias)
|
|
|
|
id, err := uuid.NewUUID()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"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 := galleryop.ManagementOp[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]any{
|
|
"jobID": uid,
|
|
"message": "External backend installation started",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
app.POST("/api/backends/delete/:id", func(c echo.Context) error {
|
|
backendID := c.Param("id")
|
|
// URL decode the backend ID
|
|
backendID, err := url.QueryUnescape(backendID)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "invalid backend ID",
|
|
})
|
|
}
|
|
xlog.Debug("API job submitted to delete backend", "backendID", backendID)
|
|
|
|
var backendName = backendID
|
|
if strings.Contains(backendID, "@") {
|
|
backendName = strings.Split(backendID, "@")[1]
|
|
}
|
|
|
|
id, err := uuid.NewUUID()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
uid := id.String()
|
|
|
|
opcache.SetBackend(backendID, uid)
|
|
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
op := galleryop.ManagementOp[gallery.GalleryBackend, any]{
|
|
ID: uid,
|
|
Delete: true,
|
|
GalleryElementName: backendName,
|
|
Galleries: appConfig.BackendGalleries,
|
|
Context: ctx,
|
|
CancelFunc: cancelFunc,
|
|
}
|
|
// 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]any{
|
|
"jobID": uid,
|
|
"message": "Backend deletion started",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
app.GET("/api/backends/job/:uid", func(c echo.Context) error {
|
|
jobUID := c.Param("uid")
|
|
|
|
status := galleryService.GetStatus(jobUID)
|
|
if status == nil {
|
|
// Job is queued but hasn't started processing yet
|
|
return c.JSON(200, map[string]any{
|
|
"progress": 0,
|
|
"message": "Operation queued",
|
|
"galleryElementName": "",
|
|
"processed": false,
|
|
"deletion": false,
|
|
"queued": true,
|
|
})
|
|
}
|
|
|
|
response := map[string]any{
|
|
"progress": status.Progress,
|
|
"message": status.Message,
|
|
"galleryElementName": status.GalleryElementName,
|
|
"processed": status.Processed,
|
|
"deletion": status.Deletion,
|
|
"queued": false,
|
|
}
|
|
|
|
if status.Error != nil {
|
|
response["error"] = status.Error.Error()
|
|
}
|
|
|
|
if status.Progress == 100 && status.Processed && status.Message == "completed" {
|
|
opcache.DeleteUUID(jobUID)
|
|
response["completed"] = true
|
|
}
|
|
|
|
return c.JSON(200, response)
|
|
}, adminMiddleware)
|
|
|
|
// System Backend Deletion API (for installed backends on index page)
|
|
app.POST("/api/backends/system/delete/:name", func(c echo.Context) error {
|
|
backendName := c.Param("name")
|
|
// URL decode the backend name
|
|
backendName, err := url.QueryUnescape(backendName)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "invalid backend name",
|
|
})
|
|
}
|
|
xlog.Debug("API request to delete system backend", "backendName", backendName)
|
|
|
|
// Use the gallery service's backend manager, which in distributed mode
|
|
// fans out deletion to worker nodes via NATS.
|
|
if err := galleryService.DeleteBackend(backendName); err != nil {
|
|
xlog.Error("Failed to delete backend", "error", err, "backendName", backendName)
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"success": true,
|
|
"message": "Backend deleted successfully",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
// Backend upgrade APIs
|
|
app.GET("/api/backends/upgrades", func(c echo.Context) error {
|
|
if applicationInstance == nil || applicationInstance.UpgradeChecker() == nil {
|
|
return c.JSON(200, map[string]any{})
|
|
}
|
|
return c.JSON(200, applicationInstance.UpgradeChecker().GetAvailableUpgrades())
|
|
}, adminMiddleware)
|
|
|
|
app.POST("/api/backends/upgrades/check", func(c echo.Context) error {
|
|
if applicationInstance == nil || applicationInstance.UpgradeChecker() == nil {
|
|
return c.JSON(200, map[string]any{})
|
|
}
|
|
applicationInstance.UpgradeChecker().TriggerCheck()
|
|
return c.JSON(200, applicationInstance.UpgradeChecker().GetAvailableUpgrades())
|
|
}, adminMiddleware)
|
|
|
|
app.POST("/api/backends/upgrade/:name", func(c echo.Context) error {
|
|
backendName := c.Param("name")
|
|
backendName, err := url.QueryUnescape(backendName)
|
|
if err != nil {
|
|
return c.JSON(http.StatusBadRequest, map[string]any{
|
|
"error": "invalid backend name",
|
|
})
|
|
}
|
|
|
|
id, err := uuid.NewUUID()
|
|
if err != nil {
|
|
return c.JSON(http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
|
}
|
|
|
|
uid := id.String()
|
|
|
|
// Register in opcache so the operation shows up in /api/operations
|
|
// and the Backends UI can reflect progress on the affected row.
|
|
opcache.SetBackend(backendName, uid)
|
|
|
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
|
op := galleryop.ManagementOp[gallery.GalleryBackend, any]{
|
|
ID: uid,
|
|
GalleryElementName: backendName,
|
|
Galleries: appConfig.BackendGalleries,
|
|
Upgrade: true,
|
|
Context: ctx,
|
|
CancelFunc: cancelFunc,
|
|
}
|
|
// Store cancellation function immediately so queued operations can be cancelled
|
|
galleryService.StoreCancellation(uid, cancelFunc)
|
|
// Non-blocking send — BackendGalleryChannel is unbuffered and a direct
|
|
// send would hang the HTTP handler whenever the worker is busy.
|
|
go func() {
|
|
galleryService.BackendGalleryChannel <- op
|
|
}()
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"jobID": uid,
|
|
"uuid": uid,
|
|
"statusUrl": fmt.Sprintf("/api/backends/job/%s", uid),
|
|
"message": "Backend upgrade started",
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
// P2P APIs
|
|
app.GET("/api/p2p/workers", func(c echo.Context) error {
|
|
llamaNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.LlamaCPPWorkerID))
|
|
mlxNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.MLXWorkerID))
|
|
|
|
llamaJSON := make([]map[string]any, 0, len(llamaNodes))
|
|
for _, n := range llamaNodes {
|
|
llamaJSON = append(llamaJSON, map[string]any{
|
|
"name": n.Name,
|
|
"id": n.ID,
|
|
"tunnelAddress": n.TunnelAddress,
|
|
"serviceID": n.ServiceID,
|
|
"lastSeen": n.LastSeen,
|
|
"isOnline": n.IsOnline(),
|
|
})
|
|
}
|
|
|
|
mlxJSON := make([]map[string]any, 0, len(mlxNodes))
|
|
for _, n := range mlxNodes {
|
|
mlxJSON = append(mlxJSON, map[string]any{
|
|
"name": n.Name,
|
|
"id": n.ID,
|
|
"tunnelAddress": n.TunnelAddress,
|
|
"serviceID": n.ServiceID,
|
|
"lastSeen": n.LastSeen,
|
|
"isOnline": n.IsOnline(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"llama_cpp": map[string]any{
|
|
"nodes": llamaJSON,
|
|
},
|
|
"mlx": map[string]any{
|
|
"nodes": mlxJSON,
|
|
},
|
|
// Keep backward-compatible "nodes" key with llama.cpp workers
|
|
"nodes": llamaJSON,
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
app.GET("/api/p2p/federation", func(c echo.Context) error {
|
|
nodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))
|
|
|
|
nodesJSON := make([]map[string]any, 0, len(nodes))
|
|
for _, n := range nodes {
|
|
nodesJSON = append(nodesJSON, map[string]any{
|
|
"name": n.Name,
|
|
"id": n.ID,
|
|
"tunnelAddress": n.TunnelAddress,
|
|
"serviceID": n.ServiceID,
|
|
"lastSeen": n.LastSeen,
|
|
"isOnline": n.IsOnline(),
|
|
})
|
|
}
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"nodes": nodesJSON,
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
app.GET("/api/p2p/stats", func(c echo.Context) error {
|
|
llamaCPPNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.LlamaCPPWorkerID))
|
|
federatedNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.FederatedID))
|
|
mlxWorkerNodes := p2p.GetAvailableNodes(p2p.NetworkID(appConfig.P2PNetworkID, p2p.MLXWorkerID))
|
|
|
|
llamaCPPOnline := 0
|
|
for _, n := range llamaCPPNodes {
|
|
if n.IsOnline() {
|
|
llamaCPPOnline++
|
|
}
|
|
}
|
|
|
|
federatedOnline := 0
|
|
for _, n := range federatedNodes {
|
|
if n.IsOnline() {
|
|
federatedOnline++
|
|
}
|
|
}
|
|
|
|
mlxWorkersOnline := 0
|
|
for _, n := range mlxWorkerNodes {
|
|
if n.IsOnline() {
|
|
mlxWorkersOnline++
|
|
}
|
|
}
|
|
|
|
return c.JSON(200, map[string]any{
|
|
"llama_cpp_workers": map[string]any{
|
|
"online": llamaCPPOnline,
|
|
"total": len(llamaCPPNodes),
|
|
},
|
|
"federated": map[string]any{
|
|
"online": federatedOnline,
|
|
"total": len(federatedNodes),
|
|
},
|
|
"mlx_workers": map[string]any{
|
|
"online": mlxWorkersOnline,
|
|
"total": len(mlxWorkerNodes),
|
|
},
|
|
})
|
|
}, adminMiddleware)
|
|
|
|
// Resources API endpoint - unified memory info (GPU if available, otherwise RAM)
|
|
app.GET("/api/resources", func(c echo.Context) error {
|
|
resourceInfo := xsysinfo.GetResourceInfo()
|
|
|
|
// Format watchdog interval
|
|
watchdogInterval := "2s" // default
|
|
if appConfig.WatchDogInterval > 0 {
|
|
watchdogInterval = appConfig.WatchDogInterval.String()
|
|
}
|
|
|
|
storageSize, _ := getDirectorySize(appConfig.SystemState.Model.ModelsPath)
|
|
|
|
response := map[string]any{
|
|
"type": resourceInfo.Type, // "gpu" or "ram"
|
|
"available": resourceInfo.Available,
|
|
"gpus": resourceInfo.GPUs,
|
|
"ram": resourceInfo.RAM,
|
|
"aggregate": resourceInfo.Aggregate,
|
|
"storage_size": storageSize,
|
|
"reclaimer_enabled": appConfig.MemoryReclaimerEnabled,
|
|
"reclaimer_threshold": appConfig.MemoryReclaimerThreshold,
|
|
"watchdog_interval": watchdogInterval,
|
|
}
|
|
|
|
return c.JSON(200, response)
|
|
}, adminMiddleware)
|
|
|
|
if !appConfig.DisableRuntimeSettings {
|
|
// Settings API
|
|
app.GET("/api/settings", localai.GetSettingsEndpoint(applicationInstance), adminMiddleware)
|
|
app.POST("/api/settings", localai.UpdateSettingsEndpoint(applicationInstance), adminMiddleware)
|
|
}
|
|
|
|
}
|
|
|