mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-31 10:28:43 -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:
40
core/http/react-ui/e2e/model-artifact-operation.spec.js
Normal file
40
core/http/react-ui/e2e/model-artifact-operation.spec.js
Normal 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')
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user