Skip to content

Commit 279f5b8

Browse files
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>
1 parent 9edb08e commit 279f5b8

6 files changed

Lines changed: 78 additions & 5 deletions

File tree

core/backend/options.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,16 @@ func estimateModelSizeBytes(c config.ModelConfig, modelsPath string) int64 {
131131
}
132132
}
133133
if managedPrimary {
134-
relative := c.ModelFileName()
135-
manifest, err := modelartifacts.ReadManifest(filepath.Join(modelsPath, filepath.Dir(relative), "manifest.json"))
136-
if err == nil {
137-
for _, file := range manifest.Files {
138-
addFile(filepath.Join(relative, filepath.FromSlash(file.Path)), file.Size)
134+
// The snapshot directory is derived from the cache key, not from
135+
// ModelFileName(): for a single-file artifact ModelFileName() resolves to
136+
// the file inside the snapshot, whereas the manifest and every artifact
137+
// file live relative to the snapshot directory itself.
138+
if snapshotDir, err := modelartifacts.RelativeSnapshotPath(c.Artifacts[0].Resolved.CacheKey); err == nil {
139+
manifest, err := modelartifacts.ReadManifest(filepath.Join(modelsPath, filepath.Dir(snapshotDir), "manifest.json"))
140+
if err == nil {
141+
for _, file := range manifest.Files {
142+
addFile(filepath.Join(snapshotDir, filepath.FromSlash(file.Path)), file.Size)
143+
}
139144
}
140145
}
141146
} else {

core/config/model_config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7+
"path/filepath"
78
"regexp"
89
"slices"
910
"strings"
@@ -1218,6 +1219,12 @@ func (c *ModelConfig) ModelFileName() string {
12181219
if len(c.Artifacts) > 0 && c.Artifacts[0].Resolved != nil {
12191220
relative, err := modelartifacts.RelativeSnapshotPath(c.Artifacts[0].Resolved.CacheKey)
12201221
if err == nil {
1222+
// Single-file snapshots (e.g. a GGUF) must resolve to the file inside
1223+
// the snapshot directory; single-file backends load a file, not a dir.
1224+
// Multi-file snapshots keep pointing at the directory.
1225+
if primary := c.Artifacts[0].Resolved.PrimaryFile; primary != "" {
1226+
return filepath.Join(relative, filepath.FromSlash(primary))
1227+
}
12211228
return relative
12221229
}
12231230
}

core/config/model_config_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,24 @@ parameters:
6565
Expect(cfg.ModelFileName()).To(Equal(filepath.Join(".artifacts", "huggingface", cacheKey, "snapshot")))
6666
})
6767

68+
It("resolves a single-file managed snapshot to the file inside the snapshot", func() {
69+
const cacheKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
70+
cfg := ModelConfig{
71+
Artifacts: []modelartifacts.Spec{{
72+
Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo"},
73+
Resolved: &modelartifacts.Resolved{
74+
CacheKey: cacheKey,
75+
PrimaryFile: "nomic-embed-text-v1.5.f16.gguf",
76+
},
77+
}},
78+
}
79+
cfg.Model = "huggingface://owner/repo/nomic-embed-text-v1.5.f16.gguf"
80+
// A single-file GGUF must resolve to the file itself, never the snapshot
81+
// directory, or the backend fails with "failed to read magic".
82+
Expect(cfg.ModelFileName()).To(Equal(
83+
filepath.Join(".artifacts", "huggingface", cacheKey, "snapshot", "nomic-embed-text-v1.5.f16.gguf")))
84+
})
85+
6886
Context("Test Read configuration functions", func() {
6987
It("Test Validate", func() {
7088
tmp, err := os.CreateTemp("", "config.yaml")

pkg/modelartifacts/materializer.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,14 @@ func (m *Manager) Ensure(ctx context.Context, modelsPath string, spec Spec) (Res
127127
return Result{}, fmt.Errorf("resolved artifact identity changed; reinstall the model")
128128
}
129129
normalized.Resolved = &Resolved{Endpoint: snapshot.Endpoint, Revision: snapshot.ResolvedRevision}
130+
// A snapshot with exactly one file is a single-file model (e.g. a GGUF for
131+
// llama.cpp/whisper). Record it so the load target resolves to the file
132+
// itself rather than the snapshot directory. PrimaryFile is deliberately not
133+
// part of the cache key: it is derived from the resolved contents, not the
134+
// request identity.
135+
if len(snapshot.Files) == 1 {
136+
normalized.Resolved.PrimaryFile = snapshot.Files[0].Path
137+
}
130138
cacheKey, err := CacheKey(normalized)
131139
if err != nil {
132140
return Result{}, err

pkg/modelartifacts/materializer_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ var _ = Describe("controller artifact materializer", func() {
7474
Expect(err).NotTo(HaveOccurred())
7575
Expect(result.CacheHit).To(BeFalse())
7676
Expect(result.Spec.Resolved.Revision).To(Equal("0123456789abcdef0123456789abcdef01234567"))
77+
// A snapshot with a single file records it as the primary file so the
78+
// load target is the file, not the snapshot directory.
79+
Expect(result.Spec.Resolved.PrimaryFile).To(Equal("nested/model.safetensors"))
7780
Expect(result.RelativePath).To(HavePrefix(".artifacts/huggingface/"))
7881
Expect(os.ReadFile(filepath.Join(modelsPath, filepath.FromSlash(result.RelativePath), "nested", "model.safetensors"))).To(Equal(weight))
7982

@@ -88,6 +91,25 @@ var _ = Describe("controller artifact materializer", func() {
8891
Expect(resolver.callCount()).To(Equal(1))
8992
})
9093

94+
It("leaves the primary file unset for a multi-file snapshot", func() {
95+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("x")) }))
96+
DeferCleanup(server.Close)
97+
resolver := &fakeSnapshotResolver{snapshot: hfapi.Snapshot{
98+
Endpoint: "https://huggingface.co", Repo: "owner/repo",
99+
ResolvedRevision: "0123456789abcdef0123456789abcdef01234567",
100+
Files: []hfapi.SnapshotFile{
101+
{Path: "config.json", Size: 1, URL: server.URL + "/config"},
102+
{Path: "model.safetensors", Size: 1, URL: server.URL + "/model"},
103+
},
104+
}}
105+
result, err := modelartifacts.NewManager(resolver).Ensure(context.Background(), GinkgoT().TempDir(),
106+
modelartifacts.Spec{Source: modelartifacts.Source{Type: "huggingface", Repo: "owner/repo"}})
107+
Expect(err).NotTo(HaveOccurred())
108+
// Multi-file snapshots are consumed as a directory (e.g. transformers), so
109+
// no single file is promoted.
110+
Expect(result.Spec.Resolved.PrimaryFile).To(BeEmpty())
111+
})
112+
91113
It("rejects a path escape before opening a destination", func() {
92114
resolver := &fakeSnapshotResolver{snapshot: hfapi.Snapshot{
93115
Endpoint: "https://huggingface.co", Repo: "owner/repo",

pkg/modelartifacts/types.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ type Resolved struct {
4040
Endpoint string `yaml:"endpoint" json:"endpoint"`
4141
Revision string `yaml:"revision" json:"revision"`
4242
CacheKey string `yaml:"cache_key" json:"cache_key"`
43+
// PrimaryFile is the slash-separated path, relative to the snapshot root, of
44+
// the single model file to load when the resolved snapshot contains exactly
45+
// one file. Single-file backends (llama.cpp, whisper, ...) must be pointed at
46+
// this file rather than the snapshot directory. It is empty for multi-file
47+
// snapshots, where the snapshot directory itself is the load target (e.g. the
48+
// Python/transformers backends consuming a full repo).
49+
PrimaryFile string `yaml:"primary_file,omitempty" json:"primary_file,omitempty"`
4350
}
4451

4552
func (s Spec) Normalize() (Spec, error) {
@@ -103,6 +110,12 @@ func (s Spec) Normalize() (Spec, error) {
103110
if resolved.CacheKey != "" && !cacheKeyPattern.MatchString(resolved.CacheKey) {
104111
return Spec{}, fmt.Errorf("resolved cache key must be 64 lowercase hexadecimal characters")
105112
}
113+
resolved.PrimaryFile = strings.TrimSpace(resolved.PrimaryFile)
114+
if resolved.PrimaryFile != "" {
115+
if err := ValidateRelativeHubPath(resolved.PrimaryFile); err != nil {
116+
return Spec{}, err
117+
}
118+
}
106119
s.Resolved = &resolved
107120
}
108121

0 commit comments

Comments
 (0)