mirror of
https://github.com/mudler/LocalAI.git
synced 2026-08-01 11:00:24 -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:
153
pkg/modelartifacts/materializer_test.go
Normal file
153
pkg/modelartifacts/materializer_test.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package modelartifacts_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
|
||||
hfapi "github.com/mudler/LocalAI/pkg/huggingface-api"
|
||||
"github.com/mudler/LocalAI/pkg/modelartifacts"
|
||||
)
|
||||
|
||||
type fakeSnapshotResolver struct {
|
||||
mu sync.Mutex
|
||||
snapshot hfapi.Snapshot
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeSnapshotResolver) ResolveSnapshot(context.Context, hfapi.SnapshotRequest) (hfapi.Snapshot, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.calls++
|
||||
return f.snapshot, f.err
|
||||
}
|
||||
|
||||
func (f *fakeSnapshotResolver) callCount() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.calls
|
||||
}
|
||||
|
||||
var _ = Describe("controller artifact materializer", func() {
|
||||
It("requires an injected token before resolving a private artifact", func() {
|
||||
resolver := &fakeSnapshotResolver{}
|
||||
_, err := modelartifacts.NewManager(resolver).Ensure(context.Background(), GinkgoT().TempDir(),
|
||||
modelartifacts.Spec{Source: modelartifacts.Source{
|
||||
Type: modelartifacts.SourceTypeHuggingFace, Repo: "owner/private", TokenEnv: modelartifacts.HuggingFaceTokenEnv,
|
||||
}})
|
||||
Expect(err).To(MatchError(ContainSubstring("non-empty HF_TOKEN")))
|
||||
Expect(resolver.callCount()).To(BeZero())
|
||||
})
|
||||
|
||||
It("downloads, commits, and reuses a pinned snapshot", func() {
|
||||
weight := []byte("weight-bytes")
|
||||
sum := sha256.Sum256(weight)
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
Expect(r.Header.Get("Authorization")).To(Equal("Bearer hf-secret"))
|
||||
w.Header().Set("Content-Length", "12")
|
||||
_, _ = w.Write(weight)
|
||||
}))
|
||||
DeferCleanup(server.Close)
|
||||
|
||||
resolver := &fakeSnapshotResolver{snapshot: hfapi.Snapshot{
|
||||
Endpoint: "https://huggingface.co", Repo: "owner/repo",
|
||||
RequestedRevision: "main", ResolvedRevision: "0123456789abcdef0123456789abcdef01234567",
|
||||
Files: []hfapi.SnapshotFile{{Path: "nested/model.safetensors", Size: 12, LFSOID: hex.EncodeToString(sum[:]), URL: server.URL + "/model"}},
|
||||
}}
|
||||
manager := modelartifacts.NewManager(resolver, modelartifacts.WithHuggingFaceToken("hf-secret"))
|
||||
modelsPath := GinkgoT().TempDir()
|
||||
spec := modelartifacts.Spec{Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo", TokenEnv: "HF_TOKEN"}}
|
||||
|
||||
result, err := manager.Ensure(context.Background(), modelsPath, spec)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(result.CacheHit).To(BeFalse())
|
||||
Expect(result.Spec.Resolved.Revision).To(Equal("0123456789abcdef0123456789abcdef01234567"))
|
||||
Expect(result.RelativePath).To(HavePrefix(".artifacts/huggingface/"))
|
||||
Expect(os.ReadFile(filepath.Join(modelsPath, filepath.FromSlash(result.RelativePath), "nested", "model.safetensors"))).To(Equal(weight))
|
||||
|
||||
manifestBytes, err := os.ReadFile(filepath.Join(modelsPath, filepath.Dir(filepath.FromSlash(result.RelativePath)), "manifest.json"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(string(manifestBytes)).NotTo(ContainSubstring("hf-secret"))
|
||||
|
||||
cached, err := manager.Ensure(context.Background(), modelsPath, result.Spec)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(cached.CacheHit).To(BeTrue())
|
||||
Expect(requests.Load()).To(Equal(int32(1)))
|
||||
Expect(resolver.callCount()).To(Equal(1))
|
||||
})
|
||||
|
||||
It("rejects a path escape before opening a destination", func() {
|
||||
resolver := &fakeSnapshotResolver{snapshot: hfapi.Snapshot{
|
||||
Endpoint: "https://huggingface.co", Repo: "owner/repo",
|
||||
ResolvedRevision: "0123456789abcdef0123456789abcdef01234567",
|
||||
Files: []hfapi.SnapshotFile{{Path: "../escape", Size: 1, URL: "https://example.invalid/file"}},
|
||||
}}
|
||||
modelsPath := GinkgoT().TempDir()
|
||||
_, err := modelartifacts.NewManager(resolver).Ensure(context.Background(), modelsPath,
|
||||
modelartifacts.Spec{Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo"}})
|
||||
Expect(err).To(MatchError(ContainSubstring("unsafe Hub path")))
|
||||
Expect(filepath.Join(modelsPath, "escape")).NotTo(BeAnExistingFile())
|
||||
})
|
||||
|
||||
It("does not commit after cancellation", func() {
|
||||
started := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
close(started)
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
DeferCleanup(server.Close)
|
||||
resolver := &fakeSnapshotResolver{snapshot: hfapi.Snapshot{
|
||||
Endpoint: "https://huggingface.co", Repo: "owner/repo",
|
||||
ResolvedRevision: "0123456789abcdef0123456789abcdef01234567",
|
||||
Files: []hfapi.SnapshotFile{{Path: "model.bin", Size: 10, URL: server.URL}},
|
||||
}}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
modelsPath := GinkgoT().TempDir()
|
||||
go func() {
|
||||
_, err := modelartifacts.NewManager(resolver).Ensure(ctx, modelsPath,
|
||||
modelartifacts.Spec{Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo"}})
|
||||
done <- err
|
||||
}()
|
||||
<-started
|
||||
cancel()
|
||||
Expect(<-done).To(MatchError(context.Canceled))
|
||||
entries, err := filepath.Glob(filepath.Join(modelsPath, ".artifacts", "huggingface", "*"))
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(entries).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("emits resolving, downloading, verifying, and committing phases", func() {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("x")) }))
|
||||
DeferCleanup(server.Close)
|
||||
resolver := &fakeSnapshotResolver{snapshot: hfapi.Snapshot{
|
||||
Endpoint: "https://huggingface.co", Repo: "owner/repo",
|
||||
ResolvedRevision: "0123456789abcdef0123456789abcdef01234567",
|
||||
Files: []hfapi.SnapshotFile{{Path: "config.json", Size: 1, URL: server.URL}},
|
||||
}}
|
||||
var phases []modelartifacts.Phase
|
||||
ctx := modelartifacts.WithProgressSink(context.Background(), func(event modelartifacts.ProgressEvent) {
|
||||
if len(phases) == 0 || phases[len(phases)-1] != event.Phase {
|
||||
phases = append(phases, event.Phase)
|
||||
}
|
||||
})
|
||||
_, err := modelartifacts.NewManager(resolver).Ensure(ctx, GinkgoT().TempDir(),
|
||||
modelartifacts.Spec{Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo"}})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(phases).To(Equal([]modelartifacts.Phase{
|
||||
modelartifacts.PhaseResolving, modelartifacts.PhaseDownloading, modelartifacts.PhaseVerifying, modelartifacts.PhaseCommitting,
|
||||
}))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user