diff --git a/core/backend/options.go b/core/backend/options.go index 5dd2d1ad5..664e99af8 100644 --- a/core/backend/options.go +++ b/core/backend/options.go @@ -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 { diff --git a/core/config/model_config.go b/core/config/model_config.go index a71a2c683..65bef77cf 100644 --- a/core/config/model_config.go +++ b/core/config/model_config.go @@ -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 } } diff --git a/core/config/model_config_test.go b/core/config/model_config_test.go index c1e64858d..1b7a10f45 100644 --- a/core/config/model_config_test.go +++ b/core/config/model_config_test.go @@ -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") diff --git a/pkg/modelartifacts/materializer.go b/pkg/modelartifacts/materializer.go index da9faa5d9..e17499c77 100644 --- a/pkg/modelartifacts/materializer.go +++ b/pkg/modelartifacts/materializer.go @@ -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 diff --git a/pkg/modelartifacts/materializer_test.go b/pkg/modelartifacts/materializer_test.go index ea80c1331..ad11d0ff9 100644 --- a/pkg/modelartifacts/materializer_test.go +++ b/pkg/modelartifacts/materializer_test.go @@ -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", diff --git a/pkg/modelartifacts/types.go b/pkg/modelartifacts/types.go index 9fbc562f5..be375d909 100644 --- a/pkg/modelartifacts/types.go +++ b/pkg/modelartifacts/types.go @@ -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 }