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:
localai-org-maint-bot
2026-07-18 00:42:50 +02:00
committed by GitHub
parent 9edb08ea94
commit 279f5b8a93
6 changed files with 78 additions and 5 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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