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:
LocalAI [bot]
2026-07-15 01:09:33 +02:00
committed by GitHub
parent d82c38ee77
commit bcc41219f7
83 changed files with 4315 additions and 339 deletions

View File

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

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

View 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, &copy)
})
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)))
})
})

View File

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

View File

@@ -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())
})

View File

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

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

View File

@@ -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...)
}

View File

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

View File

@@ -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,

View File

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

View File

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