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

@@ -0,0 +1,40 @@
import { test, expect } from './coverage-fixtures.js'
test('operations bar shows managed model acquisition phase and bytes', async ({ page }) => {
await page.route('**/api/operations', (route) => route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
operations: [{
id: 'qwen-asr',
name: 'qwen-asr',
fullName: 'qwen-asr',
jobID: 'artifact-job-123',
progress: 45,
taskType: 'installation',
isDeletion: false,
isBackend: false,
isQueued: false,
isCancelled: false,
cancellable: true,
phase: 'downloading',
currentBytes: 1073741824,
totalBytes: 4294967296,
}],
}),
}))
let cancelledPath = ''
await page.route('**/api/operations/artifact-job-123/cancel', (route) => {
cancelledPath = new URL(route.request().url()).pathname
return route.fulfill({ contentType: 'application/json', body: '{}' })
})
await page.goto('/app/models')
const operation = page.locator('.operation-item').filter({ hasText: 'qwen-asr' })
await expect(operation).toContainText('Downloading model files')
await expect(operation).toContainText('1 GB / 4 GB')
await expect(operation.locator('.operation-progress')).toHaveText('45%')
await expect(operation.locator('.operation-bar')).toHaveAttribute('style', /width: 45%/)
await operation.getByTitle('Cancel').click()
expect(cancelledPath).toBe('/api/operations/artifact-job-123/cancel')
})

View File

@@ -1,5 +1,14 @@
import { useState } from 'react'
import { useOperations } from '../hooks/useOperations'
import { formatBytes } from '../utils/format'
const artifactPhaseLabels = {
resolving: 'Resolving model files',
downloading: 'Downloading model files',
verifying: 'Verifying model files',
committing: 'Finalizing model installation',
persisting: 'Saving model configuration',
}
const nodeStatusLabels = {
success: 'Done',
@@ -26,6 +35,10 @@ export default function OperationsBar() {
const nodes = Array.isArray(op.nodes) ? op.nodes : []
const canExpand = nodes.length > 1
const isOpen = !!expanded[key]
const phaseLabel = artifactPhaseLabels[op.phase]
const byteLabel = Number.isFinite(op.currentBytes) && Number.isFinite(op.totalBytes) && op.totalBytes > 0
? `${formatBytes(op.currentBytes)} / ${formatBytes(op.totalBytes)}`
: ''
return (
<div key={key} className="operation-item">
<div className="operation-info">
@@ -68,7 +81,17 @@ export default function OperationsBar() {
Cancelling...
</span>
)}
{!op.error && op.message && !op.isQueued && !op.isCancelled && (
{!op.error && phaseLabel && !op.isCancelled && (
<span className="operation-phase" style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginLeft: 'var(--spacing-xs)' }}>
{phaseLabel}
</span>
)}
{!op.error && byteLabel && !op.isCancelled && (
<span className="operation-bytes" style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginLeft: 'var(--spacing-xs)' }}>
{byteLabel}
</span>
)}
{!op.error && op.message && !phaseLabel && !op.isQueued && !op.isCancelled && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', marginLeft: 'var(--spacing-xs)' }}>
{op.message}
</span>

View File

@@ -173,6 +173,9 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
isCancelled := false
isCancellable := false
message := ""
phase := ""
currentBytes := int64(0)
totalBytes := int64(0)
if status != nil {
// Skip successfully completed operations
@@ -189,6 +192,9 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
isCancelled = status.Cancelled
isCancellable = status.Cancellable
message = status.Message
phase = status.Phase
currentBytes = status.CurrentBytes
totalBytes = status.TotalBytes
if isDeletion {
taskType = "deletion"
}
@@ -257,6 +263,15 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
if scopedNodeID != "" {
opData["nodeID"] = scopedNodeID
}
if phase != "" {
opData["phase"] = phase
}
if currentBytes > 0 {
opData["currentBytes"] = currentBytes
}
if totalBytes > 0 {
opData["totalBytes"] = totalBytes
}
if status != nil && status.Error != nil {
opData["error"] = status.Error.Error()
}
@@ -909,7 +924,8 @@ func RegisterUIAPIRoutes(app *echo.Echo, cl *config.ModelConfigLoader, ml *model
})
}
_, err = gallery.InstallModel(context.Background(), appConfig.SystemState, model.Name, &config, model.Overrides, nil, false)
_, err = gallery.InstallModel(context.Background(), appConfig.SystemState, model.Name, &config, model.Overrides, nil, false,
gallery.WithArtifactMaterializer(appConfig.ModelArtifactMaterializer))
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]any{
"error": err.Error(),

View File

@@ -13,6 +13,7 @@ import (
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/http/routes"
"github.com/mudler/LocalAI/core/services/galleryop"
"github.com/mudler/LocalAI/pkg/system"
)
// These specs guard the contract between the opcache (which stores
@@ -152,4 +153,41 @@ var _ = Describe("/api/operations with node-scoped backend ops", func() {
Expect(found).ToNot(HaveKey("nodeID"), "non-node-scoped ops must NOT carry a nodeID field")
Expect(found["name"]).To(Equal("llama-cpp"))
})
It("surfaces managed model artifact phase and byte counters", func() {
state, err := system.GetSystemState(system.WithModelPath(GinkgoT().TempDir()))
Expect(err).NotTo(HaveOccurred())
appCfg := &config.ApplicationConfig{SystemState: state}
galleryService := galleryop.NewGalleryService(appCfg, nil)
opcache := galleryop.NewOpCache(galleryService)
jobID := "test-op-artifact-progress"
opcache.Set("qwen-asr", jobID)
galleryService.UpdateStatus(jobID, &galleryop.OpStatus{
Phase: "downloading", CurrentBytes: 1024, TotalBytes: 4096,
Progress: 22.5, Message: "Downloading model file: weights.safetensors", Cancellable: true,
})
e := echo.New()
routes.RegisterUIAPIRoutes(e, nil, nil, appCfg, galleryService, opcache, &application.Application{}, noopMw)
req := httptest.NewRequest(http.MethodGet, "/api/operations", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
var envelope struct {
Operations []map[string]any `json:"operations"`
}
Expect(json.Unmarshal(rec.Body.Bytes(), &envelope)).To(Succeed())
var found map[string]any
for _, op := range envelope.Operations {
if op["jobID"] == jobID {
found = op
break
}
}
Expect(found).ToNot(BeNil())
Expect(found["phase"]).To(Equal("downloading"))
Expect(found["currentBytes"]).To(Equal(float64(1024)))
Expect(found["totalBytes"]).To(Equal(float64(4096)))
})
})