mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
fix(model-artifacts): load single-file HF snapshots from the file, not the directory (#10909)
fix(model-artifacts): load single-file HF snapshots from the file, not the dir The managed Hugging Face artifact materializer (#10825) always pointed backends at the snapshot *directory* (.artifacts/huggingface/<key>/snapshot). For a single-file model reference such as huggingface://nomic-ai/nomic-embed-text-v1.5-GGUF/nomic-embed-text-v1.5.f16.gguf, the GGUF lives *inside* that directory, so llama.cpp was handed a directory and failed with "gguf_init_from_reader: failed to read magic". This has kept the tests-aio job red on master since the feature merged (the embeddings e2e tests could not load text-embedding-ada-002). Record the single file of a one-file snapshot as Resolved.PrimaryFile and have ModelFileName() resolve to snapshot/<PrimaryFile> when it is set. Multi-file snapshots (e.g. transformers repos consumed as a directory) keep pointing at the snapshot directory. PrimaryFile is derived from the resolved contents and is deliberately excluded from the artifact cache key. estimateModelSizeBytes now derives the snapshot directory from the cache key instead of ModelFileName(), so its manifest lookup is unaffected by the file-vs-directory resolution. Assisted-by: Claude:opus-4.8 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
committed by
GitHub
parent
9edb08ea94
commit
279f5b8a93
@@ -131,11 +131,16 @@ func estimateModelSizeBytes(c config.ModelConfig, modelsPath string) int64 {
|
||||
}
|
||||
}
|
||||
if managedPrimary {
|
||||
relative := c.ModelFileName()
|
||||
manifest, err := modelartifacts.ReadManifest(filepath.Join(modelsPath, filepath.Dir(relative), "manifest.json"))
|
||||
if err == nil {
|
||||
for _, file := range manifest.Files {
|
||||
addFile(filepath.Join(relative, filepath.FromSlash(file.Path)), file.Size)
|
||||
// The snapshot directory is derived from the cache key, not from
|
||||
// ModelFileName(): for a single-file artifact ModelFileName() resolves to
|
||||
// the file inside the snapshot, whereas the manifest and every artifact
|
||||
// file live relative to the snapshot directory itself.
|
||||
if snapshotDir, err := modelartifacts.RelativeSnapshotPath(c.Artifacts[0].Resolved.CacheKey); err == nil {
|
||||
manifest, err := modelartifacts.ReadManifest(filepath.Join(modelsPath, filepath.Dir(snapshotDir), "manifest.json"))
|
||||
if err == nil {
|
||||
for _, file := range manifest.Files {
|
||||
addFile(filepath.Join(snapshotDir, filepath.FromSlash(file.Path)), file.Size)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -1218,6 +1219,12 @@ func (c *ModelConfig) ModelFileName() string {
|
||||
if len(c.Artifacts) > 0 && c.Artifacts[0].Resolved != nil {
|
||||
relative, err := modelartifacts.RelativeSnapshotPath(c.Artifacts[0].Resolved.CacheKey)
|
||||
if err == nil {
|
||||
// Single-file snapshots (e.g. a GGUF) must resolve to the file inside
|
||||
// the snapshot directory; single-file backends load a file, not a dir.
|
||||
// Multi-file snapshots keep pointing at the directory.
|
||||
if primary := c.Artifacts[0].Resolved.PrimaryFile; primary != "" {
|
||||
return filepath.Join(relative, filepath.FromSlash(primary))
|
||||
}
|
||||
return relative
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,24 @@ parameters:
|
||||
Expect(cfg.ModelFileName()).To(Equal(filepath.Join(".artifacts", "huggingface", cacheKey, "snapshot")))
|
||||
})
|
||||
|
||||
It("resolves a single-file managed snapshot to the file inside the snapshot", func() {
|
||||
const cacheKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
cfg := ModelConfig{
|
||||
Artifacts: []modelartifacts.Spec{{
|
||||
Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo"},
|
||||
Resolved: &modelartifacts.Resolved{
|
||||
CacheKey: cacheKey,
|
||||
PrimaryFile: "nomic-embed-text-v1.5.f16.gguf",
|
||||
},
|
||||
}},
|
||||
}
|
||||
cfg.Model = "huggingface://owner/repo/nomic-embed-text-v1.5.f16.gguf"
|
||||
// A single-file GGUF must resolve to the file itself, never the snapshot
|
||||
// directory, or the backend fails with "failed to read magic".
|
||||
Expect(cfg.ModelFileName()).To(Equal(
|
||||
filepath.Join(".artifacts", "huggingface", cacheKey, "snapshot", "nomic-embed-text-v1.5.f16.gguf")))
|
||||
})
|
||||
|
||||
Context("Test Read configuration functions", func() {
|
||||
It("Test Validate", func() {
|
||||
tmp, err := os.CreateTemp("", "config.yaml")
|
||||
|
||||
@@ -127,6 +127,14 @@ func (m *Manager) Ensure(ctx context.Context, modelsPath string, spec Spec) (Res
|
||||
return Result{}, fmt.Errorf("resolved artifact identity changed; reinstall the model")
|
||||
}
|
||||
normalized.Resolved = &Resolved{Endpoint: snapshot.Endpoint, Revision: snapshot.ResolvedRevision}
|
||||
// A snapshot with exactly one file is a single-file model (e.g. a GGUF for
|
||||
// llama.cpp/whisper). Record it so the load target resolves to the file
|
||||
// itself rather than the snapshot directory. PrimaryFile is deliberately not
|
||||
// part of the cache key: it is derived from the resolved contents, not the
|
||||
// request identity.
|
||||
if len(snapshot.Files) == 1 {
|
||||
normalized.Resolved.PrimaryFile = snapshot.Files[0].Path
|
||||
}
|
||||
cacheKey, err := CacheKey(normalized)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
|
||||
@@ -74,6 +74,9 @@ var _ = Describe("controller artifact materializer", func() {
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(result.CacheHit).To(BeFalse())
|
||||
Expect(result.Spec.Resolved.Revision).To(Equal("0123456789abcdef0123456789abcdef01234567"))
|
||||
// A snapshot with a single file records it as the primary file so the
|
||||
// load target is the file, not the snapshot directory.
|
||||
Expect(result.Spec.Resolved.PrimaryFile).To(Equal("nested/model.safetensors"))
|
||||
Expect(result.RelativePath).To(HavePrefix(".artifacts/huggingface/"))
|
||||
Expect(os.ReadFile(filepath.Join(modelsPath, filepath.FromSlash(result.RelativePath), "nested", "model.safetensors"))).To(Equal(weight))
|
||||
|
||||
@@ -88,6 +91,25 @@ var _ = Describe("controller artifact materializer", func() {
|
||||
Expect(resolver.callCount()).To(Equal(1))
|
||||
})
|
||||
|
||||
It("leaves the primary file unset for a multi-file snapshot", 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 + "/config"},
|
||||
{Path: "model.safetensors", Size: 1, URL: server.URL + "/model"},
|
||||
},
|
||||
}}
|
||||
result, err := modelartifacts.NewManager(resolver).Ensure(context.Background(), GinkgoT().TempDir(),
|
||||
modelartifacts.Spec{Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo"}})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
// Multi-file snapshots are consumed as a directory (e.g. transformers), so
|
||||
// no single file is promoted.
|
||||
Expect(result.Spec.Resolved.PrimaryFile).To(BeEmpty())
|
||||
})
|
||||
|
||||
It("rejects a path escape before opening a destination", func() {
|
||||
resolver := &fakeSnapshotResolver{snapshot: hfapi.Snapshot{
|
||||
Endpoint: "https://huggingface.co", Repo: "owner/repo",
|
||||
|
||||
@@ -40,6 +40,13 @@ type Resolved struct {
|
||||
Endpoint string `yaml:"endpoint" json:"endpoint"`
|
||||
Revision string `yaml:"revision" json:"revision"`
|
||||
CacheKey string `yaml:"cache_key" json:"cache_key"`
|
||||
// PrimaryFile is the slash-separated path, relative to the snapshot root, of
|
||||
// the single model file to load when the resolved snapshot contains exactly
|
||||
// one file. Single-file backends (llama.cpp, whisper, ...) must be pointed at
|
||||
// this file rather than the snapshot directory. It is empty for multi-file
|
||||
// snapshots, where the snapshot directory itself is the load target (e.g. the
|
||||
// Python/transformers backends consuming a full repo).
|
||||
PrimaryFile string `yaml:"primary_file,omitempty" json:"primary_file,omitempty"`
|
||||
}
|
||||
|
||||
func (s Spec) Normalize() (Spec, error) {
|
||||
@@ -103,6 +110,12 @@ func (s Spec) Normalize() (Spec, error) {
|
||||
if resolved.CacheKey != "" && !cacheKeyPattern.MatchString(resolved.CacheKey) {
|
||||
return Spec{}, fmt.Errorf("resolved cache key must be 64 lowercase hexadecimal characters")
|
||||
}
|
||||
resolved.PrimaryFile = strings.TrimSpace(resolved.PrimaryFile)
|
||||
if resolved.PrimaryFile != "" {
|
||||
if err := ValidateRelativeHubPath(resolved.PrimaryFile); err != nil {
|
||||
return Spec{}, err
|
||||
}
|
||||
}
|
||||
s.Resolved = &resolved
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user