mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-02 11:30:44 -04:00
feat: materialize Hugging Face model artifacts (#10825)
* feat(config): add model artifact source contract Assisted-by: Codex:GPT-5 [Codex] * feat(downloader): add authenticated raw-byte progress Assisted-by: Codex:GPT-5 [Codex] * feat(huggingface): resolve immutable snapshot manifests Assisted-by: Codex:GPT-5 [Codex] * feat(models): add artifact storage primitives Assisted-by: Codex:GPT-5 [Codex] * feat(models): materialize pinned Hugging Face snapshots Assisted-by: Codex:GPT-5 [Codex] * feat(models): bind managed snapshots at runtime Assisted-by: Codex:GPT-5 [Codex] * feat(gallery): materialize model artifacts during install Assisted-by: Codex:GPT-5 [Codex] * feat(gallery): declare managed Hugging Face artifacts Assisted-by: Codex:GPT-5 [Codex] * feat(models): preload managed model artifacts Assisted-by: Codex:GPT-5 [Codex] * fix(gallery): retain shared artifact caches on delete Assisted-by: Codex:GPT-5 [Codex] * feat(models): report artifact acquisition progress Assisted-by: Codex:GPT-5 [Codex] * refactor(backends): load managed models from ModelFile Assisted-by: Codex:GPT-5 [Codex] * refactor(backends): load staged speech model snapshots Assisted-by: Codex:GPT-5 [Codex] * refactor(backends): use staged snapshots in engine backends Assisted-by: Codex:GPT-5 [Codex] * test(distributed): cover staged artifact snapshots Assisted-by: Codex:GPT-5 [Codex] * docs: explain managed model artifacts Assisted-by: Codex:GPT-5 [Codex] * docs: add product design context Assisted-by: Codex:GPT-5 [Codex] * feat(ui): show model artifact download progress Assisted-by: Codex:GPT-5 [Codex] * Eagerly materialize Hugging Face artifacts Materialize HF-backed model references as managed GGUF artifacts during load, with lazy download retained only as fallback. Assisted-by: Codex:GPT-5 [shell] * Refactor HF downloads through a shared executor Assisted-by: Codex:GPT-5 [shell] * drop Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -25,6 +25,9 @@ type GalleryOperationRecord struct {
|
||||
OpType string `gorm:"size:32" json:"op_type"` // "model_install", "model_delete", "backend_install"
|
||||
Status string `gorm:"size:32;default:pending" json:"status"` // pending, downloading, processing, completed, failed, cancelled
|
||||
Progress float64 `json:"progress"` // 0.0 to 1.0
|
||||
Phase string `gorm:"size:32" json:"phase,omitempty"`
|
||||
CurrentBytes int64 `json:"current_bytes,omitempty"`
|
||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||
Message string `gorm:"type:text" json:"message,omitempty"`
|
||||
Error string `gorm:"type:text" json:"error,omitempty"`
|
||||
FileName string `gorm:"size:512" json:"file_name,omitempty"`
|
||||
@@ -84,14 +87,26 @@ func (s *GalleryStore) Create(op *GalleryOperationRecord) error {
|
||||
// op as still cancellable — otherwise the column keeps its Create-time zero
|
||||
// value (false), the UI hides the cancel button, and the orphaned op can only
|
||||
// be dismissed by waiting for the 30-minute stale reaper.
|
||||
func (s *GalleryStore) UpdateProgress(id string, progress float64, message, downloadedSize string, cancellable bool) error {
|
||||
return s.db.Model(&GalleryOperationRecord{}).Where("id = ?", id).Updates(map[string]any{
|
||||
type OperationProgressDetails struct {
|
||||
Phase string
|
||||
CurrentBytes int64
|
||||
TotalBytes int64
|
||||
}
|
||||
|
||||
func (s *GalleryStore) UpdateProgress(id string, progress float64, message, downloadedSize string, cancellable bool, details ...OperationProgressDetails) error {
|
||||
updates := map[string]any{
|
||||
"progress": progress,
|
||||
"message": message,
|
||||
"downloaded_file_size": downloadedSize,
|
||||
"cancellable": cancellable,
|
||||
"updated_at": time.Now(),
|
||||
}).Error
|
||||
}
|
||||
if len(details) > 0 {
|
||||
updates["phase"] = details[0].Phase
|
||||
updates["current_bytes"] = details[0].CurrentBytes
|
||||
updates["total_bytes"] = details[0].TotalBytes
|
||||
}
|
||||
return s.db.Model(&GalleryOperationRecord{}).Where("id = ?", id).Updates(updates).Error
|
||||
}
|
||||
|
||||
// UpdateStatus updates the status of an operation. A terminal status is never
|
||||
|
||||
65
core/services/galleryop/artifact_progress.go
Normal file
65
core/services/galleryop/artifact_progress.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package galleryop
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/modelartifacts"
|
||||
)
|
||||
|
||||
type artifactProgressBridge struct {
|
||||
mu sync.Mutex
|
||||
last float64
|
||||
currentBytes int64
|
||||
totalBytes int64
|
||||
update func(*OpStatus)
|
||||
}
|
||||
|
||||
func newArtifactProgressBridge(update func(*OpStatus)) *artifactProgressBridge {
|
||||
return &artifactProgressBridge{update: update}
|
||||
}
|
||||
|
||||
func (b *artifactProgressBridge) Sink(event modelartifacts.ProgressEvent) {
|
||||
b.mu.Lock()
|
||||
progress := b.last
|
||||
message := "Preparing model files"
|
||||
switch event.Phase {
|
||||
case modelartifacts.PhaseResolving:
|
||||
progress = max(progress, 0)
|
||||
message = "Resolving model files"
|
||||
case modelartifacts.PhaseDownloading:
|
||||
if event.TotalBytes > 0 {
|
||||
progress = max(progress, min(90, float64(event.CurrentBytes)*90/float64(event.TotalBytes)))
|
||||
}
|
||||
message = fmt.Sprintf("Downloading model file: %s", event.File)
|
||||
case modelartifacts.PhaseVerifying:
|
||||
progress = max(progress, 95)
|
||||
message = "Verifying model files"
|
||||
case modelartifacts.PhaseCommitting:
|
||||
progress = max(progress, 99)
|
||||
message = "Finalizing model installation"
|
||||
case modelartifacts.PhasePersisting:
|
||||
progress = max(progress, 99)
|
||||
message = "Saving model configuration"
|
||||
}
|
||||
b.last = progress
|
||||
b.currentBytes = max(b.currentBytes, event.CurrentBytes)
|
||||
b.totalBytes = max(b.totalBytes, event.TotalBytes)
|
||||
status := &OpStatus{
|
||||
Phase: string(event.Phase), Message: message, FileName: event.File,
|
||||
Progress: progress, CurrentBytes: b.currentBytes, TotalBytes: b.totalBytes,
|
||||
Cancellable: true,
|
||||
}
|
||||
update := b.update
|
||||
b.mu.Unlock()
|
||||
if update != nil {
|
||||
update(status)
|
||||
}
|
||||
}
|
||||
|
||||
func (b *artifactProgressBridge) ClampLegacy(progress float64) float64 {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
b.last = max(b.last, progress)
|
||||
return b.last
|
||||
}
|
||||
33
core/services/galleryop/artifact_progress_test.go
Normal file
33
core/services/galleryop/artifact_progress_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package galleryop
|
||||
|
||||
import (
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/pkg/modelartifacts"
|
||||
)
|
||||
|
||||
var _ = Describe("artifact operation progress", func() {
|
||||
It("maps phases and never moves percentage backward", func() {
|
||||
var statuses []*OpStatus
|
||||
bridge := newArtifactProgressBridge(func(status *OpStatus) {
|
||||
copy := *status
|
||||
statuses = append(statuses, ©)
|
||||
})
|
||||
bridge.Sink(modelartifacts.ProgressEvent{Phase: modelartifacts.PhaseResolving})
|
||||
bridge.Sink(modelartifacts.ProgressEvent{Phase: modelartifacts.PhaseDownloading, File: "model.bin", CurrentBytes: 50, TotalBytes: 100})
|
||||
bridge.Sink(modelartifacts.ProgressEvent{Phase: modelartifacts.PhaseDownloading, File: "model.bin", CurrentBytes: 10, TotalBytes: 100})
|
||||
bridge.Sink(modelartifacts.ProgressEvent{Phase: modelartifacts.PhaseVerifying, CurrentBytes: 100, TotalBytes: 100})
|
||||
bridge.Sink(modelartifacts.ProgressEvent{Phase: modelartifacts.PhaseCommitting, CurrentBytes: 100, TotalBytes: 100})
|
||||
bridge.Sink(modelartifacts.ProgressEvent{Phase: modelartifacts.PhasePersisting})
|
||||
|
||||
Expect(statuses).To(HaveLen(6))
|
||||
for index := 1; index < len(statuses); index++ {
|
||||
Expect(statuses[index].Progress).To(BeNumerically(">=", statuses[index-1].Progress))
|
||||
}
|
||||
Expect(statuses[1].Phase).To(Equal("downloading"))
|
||||
Expect(statuses[1].CurrentBytes).To(Equal(int64(50)))
|
||||
Expect(statuses[5].Progress).To(Equal(float64(99)))
|
||||
Expect(statuses[5].CurrentBytes).To(Equal(int64(100)))
|
||||
})
|
||||
})
|
||||
@@ -38,9 +38,12 @@ var _ = Describe("GalleryService cancellable persistence across restart", func()
|
||||
// Simulate a progress tick: the live path always marks installs
|
||||
// cancellable while they are downloading/processing.
|
||||
svc.UpdateStatus("op-inflight", &galleryop.OpStatus{
|
||||
Message: "downloading",
|
||||
Progress: 25,
|
||||
Cancellable: true,
|
||||
Message: "downloading",
|
||||
Progress: 25,
|
||||
Phase: "downloading",
|
||||
CurrentBytes: 123,
|
||||
TotalBytes: 456,
|
||||
Cancellable: true,
|
||||
})
|
||||
|
||||
// A fresh replica boots and hydrates from the store.
|
||||
@@ -52,5 +55,8 @@ var _ = Describe("GalleryService cancellable persistence across restart", func()
|
||||
Expect(st).ToNot(BeNil(), "the in-flight op must hydrate after a restart")
|
||||
Expect(st.Cancellable).To(BeTrue(),
|
||||
"a still-active install must rehydrate as cancellable so the admin can dismiss it")
|
||||
Expect(st.Phase).To(Equal("downloading"))
|
||||
Expect(st.CurrentBytes).To(Equal(int64(123)))
|
||||
Expect(st.TotalBytes).To(Equal(int64(456)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -122,6 +122,9 @@ var _ = Describe("OpStatus JSON wire format", func() {
|
||||
original := &galleryop.OpStatus{
|
||||
Progress: 42.0,
|
||||
Message: "downloading",
|
||||
Phase: "downloading",
|
||||
CurrentBytes: 123,
|
||||
TotalBytes: 456,
|
||||
GalleryElementName: "vllm",
|
||||
Error: errors.New("disk full"),
|
||||
Processed: true,
|
||||
@@ -134,6 +137,9 @@ var _ = Describe("OpStatus JSON wire format", func() {
|
||||
Expect(got.Error).ToNot(BeNil(), "the error must survive the round-trip — peer replicas need to surface the failure")
|
||||
Expect(got.Error.Error()).To(Equal("disk full"))
|
||||
Expect(got.Progress).To(Equal(42.0))
|
||||
Expect(got.Phase).To(Equal("downloading"))
|
||||
Expect(got.CurrentBytes).To(Equal(int64(123)))
|
||||
Expect(got.TotalBytes).To(Equal(int64(456)))
|
||||
Expect(got.GalleryElementName).To(Equal("vllm"))
|
||||
Expect(got.Processed).To(BeTrue())
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ type LocalModelManager struct {
|
||||
enforcePredownloadScans bool
|
||||
automaticallyInstallBackend bool
|
||||
requireBackendIntegrity bool
|
||||
artifactMaterializer config.ArtifactMaterializer
|
||||
}
|
||||
|
||||
// NewLocalModelManager creates a LocalModelManager from the application config.
|
||||
@@ -27,6 +28,7 @@ func NewLocalModelManager(appConfig *config.ApplicationConfig, ml *model.ModelLo
|
||||
enforcePredownloadScans: appConfig.EnforcePredownloadScans,
|
||||
automaticallyInstallBackend: appConfig.AutoloadBackendGalleries,
|
||||
requireBackendIntegrity: appConfig.RequireBackendIntegrity,
|
||||
artifactMaterializer: appConfig.ModelArtifactMaterializer,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +50,8 @@ func (m *LocalModelManager) InstallModel(ctx context.Context, op *ManagementOp[g
|
||||
switch {
|
||||
case op.GalleryElement != nil:
|
||||
installedModel, err := gallery.InstallModel(ctx, m.systemState, op.GalleryElement.Name,
|
||||
op.GalleryElement, op.Req.Overrides, progressCb, m.enforcePredownloadScans)
|
||||
op.GalleryElement, op.Req.Overrides, progressCb, m.enforcePredownloadScans,
|
||||
gallery.WithArtifactMaterializer(m.artifactMaterializer))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,10 +64,12 @@ func (m *LocalModelManager) InstallModel(ctx context.Context, op *ManagementOp[g
|
||||
case op.GalleryElementName != "":
|
||||
return gallery.InstallModelFromGallery(ctx, op.Galleries, op.BackendGalleries,
|
||||
m.systemState, m.modelLoader, op.GalleryElementName, op.Req, progressCb,
|
||||
m.enforcePredownloadScans, m.automaticallyInstallBackend, m.requireBackendIntegrity)
|
||||
m.enforcePredownloadScans, m.automaticallyInstallBackend, m.requireBackendIntegrity,
|
||||
gallery.WithArtifactMaterializer(m.artifactMaterializer))
|
||||
default:
|
||||
return installModelFromRemoteConfig(ctx, m.systemState, m.modelLoader, op.Req,
|
||||
progressCb, m.enforcePredownloadScans, m.automaticallyInstallBackend, op.BackendGalleries, m.requireBackendIntegrity)
|
||||
progressCb, m.enforcePredownloadScans, m.automaticallyInstallBackend, op.BackendGalleries, m.requireBackendIntegrity,
|
||||
gallery.WithArtifactMaterializer(m.artifactMaterializer))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
51
core/services/galleryop/model_artifact_materializer_test.go
Normal file
51
core/services/galleryop/model_artifact_materializer_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package galleryop_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"github.com/mudler/LocalAI/core/services/galleryop"
|
||||
"github.com/mudler/LocalAI/pkg/modelartifacts"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
)
|
||||
|
||||
type galleryOpArtifactMaterializer struct {
|
||||
seen []modelartifacts.Spec
|
||||
}
|
||||
|
||||
func (f *galleryOpArtifactMaterializer) Ensure(_ context.Context, _ string, spec modelartifacts.Spec) (modelartifacts.Result, error) {
|
||||
f.seen = append(f.seen, spec)
|
||||
spec.Resolved = &modelartifacts.Resolved{
|
||||
Endpoint: "https://huggingface.co",
|
||||
Revision: "0123456789abcdef0123456789abcdef01234567",
|
||||
CacheKey: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
}
|
||||
return modelartifacts.Result{Spec: spec}, nil
|
||||
}
|
||||
|
||||
var _ = Describe("local model manager artifact materializer", func() {
|
||||
It("passes the controller materializer to direct gallery installs", func() {
|
||||
state, err := system.GetSystemState(system.WithModelPath(GinkgoT().TempDir()))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
materializer := &galleryOpArtifactMaterializer{}
|
||||
manager := galleryop.NewLocalModelManager(&config.ApplicationConfig{
|
||||
SystemState: state,
|
||||
ModelArtifactMaterializer: materializer,
|
||||
}, nil)
|
||||
definition := &gallery.ModelConfig{Name: "managed", ConfigFile: `
|
||||
backend: transformers
|
||||
artifacts:
|
||||
- source: {type: huggingface, repo: owner/repo}
|
||||
parameters: {model: owner/repo}
|
||||
`}
|
||||
op := &galleryop.ManagementOp[gallery.GalleryModel, gallery.ModelConfig]{
|
||||
GalleryElement: definition,
|
||||
}
|
||||
Expect(manager.InstallModel(context.Background(), op, nil)).To(Succeed())
|
||||
Expect(materializer.seen).To(HaveLen(1))
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
"github.com/mudler/LocalAI/core/services/messaging"
|
||||
"github.com/mudler/LocalAI/pkg/model"
|
||||
"github.com/mudler/LocalAI/pkg/modelartifacts"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
"github.com/mudler/LocalAI/pkg/utils"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -52,6 +53,16 @@ func (g *GalleryService) modelHandler(op *ManagementOp[gallery.GalleryModel, gal
|
||||
|
||||
g.UpdateStatus(op.ID, &OpStatus{Message: fmt.Sprintf("processing model: %s", op.GalleryElementName), Progress: 0, Cancellable: true})
|
||||
|
||||
bridge := newArtifactProgressBridge(func(status *OpStatus) {
|
||||
status.GalleryElementName = op.GalleryElementName
|
||||
g.UpdateStatus(op.ID, status)
|
||||
})
|
||||
operationCtx := op.Context
|
||||
if operationCtx == nil {
|
||||
operationCtx = context.Background()
|
||||
}
|
||||
operationCtx = modelartifacts.WithProgressSink(operationCtx, bridge.Sink)
|
||||
|
||||
// displayDownload displays the download progress
|
||||
progressCallback := func(fileName string, current string, total string, percentage float64) {
|
||||
// Check for cancellation during progress updates
|
||||
@@ -62,6 +73,7 @@ func (g *GalleryService) modelHandler(op *ManagementOp[gallery.GalleryModel, gal
|
||||
default:
|
||||
}
|
||||
}
|
||||
percentage = bridge.ClampLegacy(percentage)
|
||||
g.UpdateStatus(op.ID, &OpStatus{Message: fmt.Sprintf(processingMessage, fileName, total, current), FileName: fileName, Progress: percentage, TotalFileSize: total, DownloadedFileSize: current, Cancellable: true})
|
||||
utils.DisplayDownloadFunction(fileName, current, total, percentage)
|
||||
}
|
||||
@@ -70,7 +82,7 @@ func (g *GalleryService) modelHandler(op *ManagementOp[gallery.GalleryModel, gal
|
||||
if op.Delete {
|
||||
err = g.modelManager.DeleteModel(op.GalleryElementName)
|
||||
} else {
|
||||
err = g.modelManager.InstallModel(op.Context, op, progressCallback)
|
||||
err = g.modelManager.InstallModel(operationCtx, op, progressCallback)
|
||||
}
|
||||
if err != nil {
|
||||
// Check if error is due to cancellation
|
||||
@@ -107,7 +119,7 @@ func (g *GalleryService) modelHandler(op *ManagementOp[gallery.GalleryModel, gal
|
||||
return err
|
||||
}
|
||||
|
||||
err = cl.Preload(systemState.Model.ModelsPath)
|
||||
err = cl.PreloadWithContext(operationCtx, systemState.Model.ModelsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -137,7 +149,7 @@ func (g *GalleryService) modelHandler(op *ManagementOp[gallery.GalleryModel, gal
|
||||
return nil
|
||||
}
|
||||
|
||||
func installModelFromRemoteConfig(ctx context.Context, systemState *system.SystemState, modelLoader *model.ModelLoader, req gallery.GalleryModel, downloadStatus func(string, string, string, float64), enforceScan, automaticallyInstallBackend bool, backendGalleries []config.Gallery, requireBackendIntegrity bool) error {
|
||||
func installModelFromRemoteConfig(ctx context.Context, systemState *system.SystemState, modelLoader *model.ModelLoader, req gallery.GalleryModel, downloadStatus func(string, string, string, float64), enforceScan, automaticallyInstallBackend bool, backendGalleries []config.Gallery, requireBackendIntegrity bool, options ...gallery.InstallOption) error {
|
||||
config, err := gallery.GetGalleryConfigFromURLWithContext[gallery.ModelConfig](ctx, req.URL, systemState.Model.ModelsPath)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -145,7 +157,7 @@ func installModelFromRemoteConfig(ctx context.Context, systemState *system.Syste
|
||||
|
||||
config.Files = append(config.Files, req.AdditionalFiles...)
|
||||
|
||||
installedModel, err := gallery.InstallModel(ctx, systemState, req.Name, &config, req.Overrides, downloadStatus, enforceScan)
|
||||
installedModel, err := gallery.InstallModel(ctx, systemState, req.Name, &config, req.Overrides, downloadStatus, enforceScan, options...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -164,23 +176,23 @@ type galleryModel struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
func processRequests(systemState *system.SystemState, modelLoader *model.ModelLoader, enforceScan, automaticallyInstallBackend bool, galleries []config.Gallery, backendGalleries []config.Gallery, requests []galleryModel, requireBackendIntegrity bool) error {
|
||||
func processRequests(systemState *system.SystemState, modelLoader *model.ModelLoader, enforceScan, automaticallyInstallBackend bool, galleries []config.Gallery, backendGalleries []config.Gallery, requests []galleryModel, requireBackendIntegrity bool, options ...gallery.InstallOption) error {
|
||||
ctx := context.Background()
|
||||
var err error
|
||||
for _, r := range requests {
|
||||
utils.ResetDownloadTimers()
|
||||
if r.ID == "" {
|
||||
err = installModelFromRemoteConfig(ctx, systemState, modelLoader, r.GalleryModel, utils.DisplayDownloadFunction, enforceScan, automaticallyInstallBackend, backendGalleries, requireBackendIntegrity)
|
||||
err = installModelFromRemoteConfig(ctx, systemState, modelLoader, r.GalleryModel, utils.DisplayDownloadFunction, enforceScan, automaticallyInstallBackend, backendGalleries, requireBackendIntegrity, options...)
|
||||
|
||||
} else {
|
||||
err = gallery.InstallModelFromGallery(
|
||||
ctx, galleries, backendGalleries, systemState, modelLoader, r.ID, r.GalleryModel, utils.DisplayDownloadFunction, enforceScan, automaticallyInstallBackend, requireBackendIntegrity)
|
||||
ctx, galleries, backendGalleries, systemState, modelLoader, r.ID, r.GalleryModel, utils.DisplayDownloadFunction, enforceScan, automaticallyInstallBackend, requireBackendIntegrity, options...)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func ApplyGalleryFromFile(systemState *system.SystemState, modelLoader *model.ModelLoader, enforceScan, automaticallyInstallBackend bool, galleries []config.Gallery, backendGalleries []config.Gallery, s string, requireBackendIntegrity bool) error {
|
||||
func ApplyGalleryFromFile(systemState *system.SystemState, modelLoader *model.ModelLoader, enforceScan, automaticallyInstallBackend bool, galleries []config.Gallery, backendGalleries []config.Gallery, s string, requireBackendIntegrity bool, options ...gallery.InstallOption) error {
|
||||
dat, err := os.ReadFile(s)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -191,15 +203,15 @@ func ApplyGalleryFromFile(systemState *system.SystemState, modelLoader *model.Mo
|
||||
return err
|
||||
}
|
||||
|
||||
return processRequests(systemState, modelLoader, enforceScan, automaticallyInstallBackend, galleries, backendGalleries, requests, requireBackendIntegrity)
|
||||
return processRequests(systemState, modelLoader, enforceScan, automaticallyInstallBackend, galleries, backendGalleries, requests, requireBackendIntegrity, options...)
|
||||
}
|
||||
|
||||
func ApplyGalleryFromString(systemState *system.SystemState, modelLoader *model.ModelLoader, enforceScan, automaticallyInstallBackend bool, galleries []config.Gallery, backendGalleries []config.Gallery, s string, requireBackendIntegrity bool) error {
|
||||
func ApplyGalleryFromString(systemState *system.SystemState, modelLoader *model.ModelLoader, enforceScan, automaticallyInstallBackend bool, galleries []config.Gallery, backendGalleries []config.Gallery, s string, requireBackendIntegrity bool, options ...gallery.InstallOption) error {
|
||||
var requests []galleryModel
|
||||
err := json.Unmarshal([]byte(s), &requests)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return processRequests(systemState, modelLoader, enforceScan, automaticallyInstallBackend, galleries, backendGalleries, requests, requireBackendIntegrity)
|
||||
return processRequests(systemState, modelLoader, enforceScan, automaticallyInstallBackend, galleries, backendGalleries, requests, requireBackendIntegrity, options...)
|
||||
}
|
||||
|
||||
@@ -61,6 +61,9 @@ type OpStatus struct {
|
||||
Processed bool `json:"processed"`
|
||||
Message string `json:"message"`
|
||||
Progress float64 `json:"progress"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
CurrentBytes int64 `json:"current_bytes,omitempty"`
|
||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||
TotalFileSize string `json:"file_size"`
|
||||
DownloadedFileSize string `json:"downloaded_size"`
|
||||
GalleryElementName string `json:"gallery_element_name"`
|
||||
@@ -89,6 +92,9 @@ type opStatusWire struct {
|
||||
Processed bool `json:"processed"`
|
||||
Message string `json:"message"`
|
||||
Progress float64 `json:"progress"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
CurrentBytes int64 `json:"current_bytes,omitempty"`
|
||||
TotalBytes int64 `json:"total_bytes,omitempty"`
|
||||
TotalFileSize string `json:"file_size"`
|
||||
DownloadedFileSize string `json:"downloaded_size"`
|
||||
GalleryElementName string `json:"gallery_element_name"`
|
||||
@@ -104,6 +110,9 @@ func (o OpStatus) MarshalJSON() ([]byte, error) {
|
||||
Processed: o.Processed,
|
||||
Message: o.Message,
|
||||
Progress: o.Progress,
|
||||
Phase: o.Phase,
|
||||
CurrentBytes: o.CurrentBytes,
|
||||
TotalBytes: o.TotalBytes,
|
||||
TotalFileSize: o.TotalFileSize,
|
||||
DownloadedFileSize: o.DownloadedFileSize,
|
||||
GalleryElementName: o.GalleryElementName,
|
||||
@@ -127,6 +136,9 @@ func (o *OpStatus) UnmarshalJSON(data []byte) error {
|
||||
o.Processed = w.Processed
|
||||
o.Message = w.Message
|
||||
o.Progress = w.Progress
|
||||
o.Phase = w.Phase
|
||||
o.CurrentBytes = w.CurrentBytes
|
||||
o.TotalBytes = w.TotalBytes
|
||||
o.TotalFileSize = w.TotalFileSize
|
||||
o.DownloadedFileSize = w.DownloadedFileSize
|
||||
o.GalleryElementName = w.GalleryElementName
|
||||
|
||||
@@ -94,6 +94,15 @@ func (g *GalleryService) BackendManager() BackendManager {
|
||||
return g.backendManager
|
||||
}
|
||||
|
||||
// ModelArtifactMaterializer returns the controller-only acquisition capability
|
||||
// used by startup paths that install gallery entries outside the operation loop.
|
||||
func (g *GalleryService) ModelArtifactMaterializer() config.ArtifactMaterializer {
|
||||
if g == nil || g.appConfig == nil {
|
||||
return nil
|
||||
}
|
||||
return g.appConfig.ModelArtifactMaterializer
|
||||
}
|
||||
|
||||
// SetNATSClient sets the NATS client for distributed progress publishing.
|
||||
// Accepting the wider MessagingClient (vs. plain Publisher) lets
|
||||
// SubscribeBroadcasts wire the wildcard subscriptions that keep peer
|
||||
@@ -167,7 +176,10 @@ func (g *GalleryService) UpdateStatus(s string, op *OpStatus) {
|
||||
xlog.Warn("Failed to persist gallery operation status", "op_id", s, "error", err)
|
||||
}
|
||||
} else {
|
||||
if err := store.UpdateProgress(s, op.Progress, op.Message, op.DownloadedFileSize, op.Cancellable); err != nil {
|
||||
if err := store.UpdateProgress(s, op.Progress, op.Message, op.DownloadedFileSize, op.Cancellable,
|
||||
distributed.OperationProgressDetails{
|
||||
Phase: op.Phase, CurrentBytes: op.CurrentBytes, TotalBytes: op.TotalBytes,
|
||||
}); err != nil {
|
||||
xlog.Warn("Failed to persist gallery operation progress", "op_id", s, "error", err)
|
||||
}
|
||||
}
|
||||
@@ -662,6 +674,9 @@ func (g *GalleryService) Hydrate() error {
|
||||
st := &OpStatus{
|
||||
Message: op.Message,
|
||||
Progress: op.Progress,
|
||||
Phase: op.Phase,
|
||||
CurrentBytes: op.CurrentBytes,
|
||||
TotalBytes: op.TotalBytes,
|
||||
FileName: op.FileName,
|
||||
TotalFileSize: op.TotalFileSize,
|
||||
DownloadedFileSize: op.DownloadedFileSize,
|
||||
|
||||
@@ -77,4 +77,30 @@ var _ = Describe("ApplyRemoteChange", func() {
|
||||
Expect(ok1).To(BeTrue())
|
||||
Expect(ok2).To(BeTrue())
|
||||
})
|
||||
|
||||
It("loads a peer-persisted artifact binding without materializing", func() {
|
||||
const relative = ".artifacts/huggingface/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/snapshot"
|
||||
writeYAML("peer-managed", map[string]any{
|
||||
"backend": "transformers",
|
||||
"artifacts": []map[string]any{{
|
||||
"name": "model", "target": "model",
|
||||
"source": map[string]any{"type": "huggingface", "repo": "owner/repo", "revision": "main"},
|
||||
"resolved": map[string]any{
|
||||
"endpoint": "https://huggingface.co",
|
||||
"revision": "0123456789abcdef0123456789abcdef01234567",
|
||||
"cache_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
|
||||
},
|
||||
}},
|
||||
"parameters": map[string]any{"model": "owner/repo"},
|
||||
})
|
||||
Expect(ApplyRemoteChange(loader, nil, dir, messaging.CacheInvalidateEvent{
|
||||
Element: "peer-managed", Op: "install",
|
||||
})).To(Succeed())
|
||||
loaded, found := loader.GetModelConfig("peer-managed")
|
||||
Expect(found).To(BeTrue())
|
||||
Expect(loaded.Model).To(Equal("owner/repo"))
|
||||
Expect(loaded.ModelFileName()).To(Equal(relative))
|
||||
Expect(loaded.Artifacts).To(HaveLen(1))
|
||||
Expect(loaded.Artifacts[0].Resolved.CacheKey).To(HaveLen(64))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
"github.com/mudler/LocalAI/core/services/storage"
|
||||
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
||||
)
|
||||
|
||||
@@ -61,4 +63,48 @@ var _ = Describe("stageModelFiles directory models", func() {
|
||||
Expect(staged).To(ConsistOf(weights, tokenizer))
|
||||
Expect(staged).ToNot(ContainElement(modelDir))
|
||||
})
|
||||
|
||||
It("stages a content-addressed Hugging Face snapshot with its relative tree intact", func() {
|
||||
cacheKey := strings.Repeat("a", 64)
|
||||
logicalModel := "owner/repo"
|
||||
relativeSnapshot := filepath.Join(".artifacts", "huggingface", cacheKey, "snapshot")
|
||||
snapshot := filepath.Join(tmp, "models", relativeSnapshot)
|
||||
files := map[string]string{
|
||||
"config.json": "{}",
|
||||
"model.safetensors.index.json": "{}",
|
||||
"model-00001-of-00002.safetensors": "part-1",
|
||||
"model-00002-of-00002.safetensors": "part-2",
|
||||
filepath.Join("tokenizer", "vocab.json"): "{}",
|
||||
}
|
||||
for relative, contents := range files {
|
||||
path := filepath.Join(snapshot, relative)
|
||||
Expect(os.MkdirAll(filepath.Dir(path), 0o750)).To(Succeed())
|
||||
Expect(os.WriteFile(path, []byte(contents), 0o644)).To(Succeed())
|
||||
}
|
||||
|
||||
opts := &pb.ModelOptions{Model: logicalModel, ModelFile: snapshot}
|
||||
stagedOpts, err := router.stageModelFiles(context.Background(), node, opts, "managed-model")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
|
||||
stagedPaths := make([]string, 0, len(stager.ensureCalls))
|
||||
stagedKeys := make([]string, 0, len(stager.ensureCalls))
|
||||
for _, call := range stager.ensureCalls {
|
||||
stagedPaths = append(stagedPaths, call.localPath)
|
||||
stagedKeys = append(stagedKeys, call.key)
|
||||
}
|
||||
expectedPaths := make([]string, 0, len(files))
|
||||
expectedKeys := make([]string, 0, len(files))
|
||||
for relative := range files {
|
||||
expectedPaths = append(expectedPaths, filepath.Join(snapshot, relative))
|
||||
expectedKeys = append(expectedKeys, storage.ModelKey(filepath.Join("managed-model", relative)))
|
||||
}
|
||||
Expect(stagedPaths).To(ConsistOf(expectedPaths))
|
||||
Expect(stagedKeys).To(ConsistOf(expectedKeys))
|
||||
remoteSnapshot := filepath.Join("/remote", storage.ModelKey("managed-model"))
|
||||
Expect(stagedOpts.Model).To(Equal(logicalModel))
|
||||
Expect(stagedOpts.ModelFile).To(Equal(remoteSnapshot))
|
||||
Expect(stagedOpts.ModelPath).To(Equal(remoteSnapshot))
|
||||
Expect(opts.Model).To(Equal(logicalModel))
|
||||
Expect(opts.ModelFile).To(Equal(snapshot))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user