Files
ollama/server/gguf_metadata_test.go
T
Daniel Hiltgen 4f6f739830 tests: fix metadata deletion test flake (#18493)
Test both publication/deletion orderings directly instead of polling for a short-lived temporary file with a machine-dependent timeout.
2026-09-17 16:39:42 -07:00

705 lines
20 KiB
Go

package server
import (
"encoding/binary"
"encoding/json"
"errors"
"math"
"net/http"
"os"
"path/filepath"
"reflect"
"runtime"
"slices"
"testing"
"github.com/gin-gonic/gin"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/envconfig"
gguftest "github.com/ollama/ollama/internal/testutil/gguf"
"github.com/ollama/ollama/manifest"
"github.com/ollama/ollama/types/model"
)
func TestGGUFMetadataExtraction(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
big := make([]string, 5000)
for i := range big {
big[i] = "tok"
}
perLayer := make([]int32, 64)
for i := range perLayer {
perLayer[i] = int32(i)
}
path, _ := createBinFile(t, gguftest.KV{
"general.architecture": "bert",
"bert.attention.softcap": float32(math.Inf(-1)),
"general.file_type": uint32(2),
"bert.pooling_type": uint32(1),
"bert.context_length": uint32(512),
"bert.attention.head_count_kv": perLayer,
"tokenizer.chat_template": "{{ messages }}",
"tokenizer.ggml.tokens": big,
"tokenizer.ggml.merges": big,
}, nil)
md, err := extractGGUFMetadata(path)
if err != nil {
t.Fatal(err)
}
if got := md.String("general.architecture"); got != "bert" {
t.Errorf("architecture = %q", got)
}
// Architecture-prefixed lookup, and the key order in the file must not matter.
if !md.Valid("pooling_type") {
t.Error("pooling_type not found")
}
if got := md.Int("context_length"); got != 512 {
t.Errorf("context_length = %d, want 512", got)
}
if got := md.String("tokenizer.chat_template"); got != "{{ messages }}" {
t.Errorf("chat_template = %q", got)
}
if md.Valid("vision.block_count") {
t.Error("absent key reported present")
}
// A 64-element per-layer array is real metadata and must survive.
if v, ok := md.KV["bert.attention.head_count_kv"].([]any); !ok || len(v) != 64 {
t.Errorf("per-layer array = %#v, want 64 elements", md.KV["bert.attention.head_count_kv"])
}
// The tokenizer is recorded as omitted, not silently missing.
want := map[string]bool{"tokenizer.ggml.tokens": true, "tokenizer.ggml.merges": true}
for _, key := range md.Omitted {
delete(want, key)
}
if len(want) != 0 {
t.Errorf("not reported omitted: %v (omitted=%v)", want, md.Omitted)
}
if _, ok := md.KV["tokenizer.ggml.tokens"]; ok {
t.Error("oversized array was copied into the metadata file")
}
// JSON cannot represent an infinity, and gemma3n ships one. Dropping that
// key must not cost the rest of the file.
if !slices.Contains(md.Omitted, "bert.attention.softcap") {
t.Errorf("non-finite float not omitted; omitted=%v", md.Omitted)
}
}
func TestGGUFMetadataFileRoundTrip(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
_, digest := createBinFile(t, gguftest.KV{
"general.architecture": "llama",
"llama.block_count": uint32(32),
}, nil)
first, err := readGGUFMetadata(digest)
if err != nil {
t.Fatal(err)
}
if got := first.Int("block_count"); got != 32 {
t.Fatalf("block_count = %d, want 32", got)
}
// With the blob gone, only the metadata file can answer.
if err := removeBlob(t, digest); err != nil {
t.Fatal(err)
}
second, err := readGGUFMetadata(digest)
if err != nil {
t.Fatalf("metadata file read failed: %v", err)
}
if got := second.Int("block_count"); got != 32 {
t.Fatalf("block_count from metadata file = %d, want 32", got)
}
// And it goes away with the blob.
removeGGUFMetadata(digest)
path, err := ggufMetadataPath(digest)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("metadata file still present after removal: %v", err)
}
}
// The metadata file is only ever an optimization: anything unusable must be re-extracted.
func TestGGUFMetadataUnusableFile(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
_, digest := createBinFile(t, gguftest.KV{"general.architecture": "llama"}, nil)
if _, err := readGGUFMetadata(digest); err != nil {
t.Fatal(err)
}
path, err := ggufMetadataPath(digest)
if err != nil {
t.Fatal(err)
}
for _, tt := range []struct {
name string
content []byte
}{
{"truncated", []byte(`{"kv":{"general.arch`)},
{"not json", []byte("\x00\x01\x02")},
{"empty", []byte{}},
{"wrong shape", []byte(`[1,2,3]`)},
{"no kv", []byte(`{"ollama_version":"1.2.3"}`)},
{"kv not an object", []byte(`{"kv":[]}`)},
} {
t.Run(tt.name, func(t *testing.T) {
if err := os.WriteFile(path, tt.content, 0o644); err != nil {
t.Fatal(err)
}
if _, ok := loadGGUFMetadata(path); ok {
t.Fatal("unusable metadata file reported usable")
}
md, err := readGGUFMetadata(digest)
if err != nil {
t.Fatal(err)
}
if got := md.String("general.architecture"); got != "llama" {
t.Fatalf("architecture = %q, want llama", got)
}
})
}
}
func TestGGUFMetadataRejectsMalformed(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
dir := t.TempDir()
for _, tt := range []struct {
name string
data []byte
}{
{"empty file", nil},
{"bad magic", []byte("NOPE")},
{"truncated header", []byte("GGUF\x03\x00\x00\x00")},
} {
t.Run(tt.name, func(t *testing.T) {
path := filepath.Join(dir, tt.name)
if err := os.WriteFile(path, tt.data, 0o600); err != nil {
t.Fatal(err)
}
if _, err := extractGGUFMetadata(path); err == nil {
t.Fatal("expected an error")
}
})
}
}
// The omission rule decides what never reaches the metadata file, so every array type
// has to agree on it. A miss here silently drops real metadata.
func TestGGUFMetadataOmitRule(t *testing.T) {
short, long := 64, ggufMetadataMaxArray+1
for _, tt := range []struct {
name string
keep any
omit any
}{
{"string", make([]string, short), make([]string, long)},
{"int8", make([]int8, short), make([]int8, long)},
{"int16", make([]int16, short), make([]int16, long)},
{"int32", make([]int32, short), make([]int32, long)},
{"int64", make([]int64, short), make([]int64, long)},
{"uint8", make([]uint8, short), make([]uint8, long)},
{"uint16", make([]uint16, short), make([]uint16, long)},
{"uint32", make([]uint32, short), make([]uint32, long)},
{"uint64", make([]uint64, short), make([]uint64, long)},
{"float32", make([]float32, short), make([]float32, long)},
{"float64", make([]float64, short), make([]float64, long)},
{"bool", make([]bool, short), make([]bool, long)},
} {
t.Run(tt.name, func(t *testing.T) {
if omitValue(tt.keep) {
t.Errorf("%d-element %s array omitted; real per-layer metadata would be lost", short, tt.name)
}
if !omitValue(tt.omit) {
t.Errorf("%d-element %s array kept; the tokenizer would be copied", long, tt.name)
}
})
}
// Scalars are always kept, except the floats JSON cannot encode.
for _, keep := range []any{"s", int32(0), uint32(0), float32(1.5), float64(1.5), true} {
if omitValue(keep) {
t.Errorf("scalar %#v omitted", keep)
}
}
for _, omit := range []any{
float32(math.Inf(1)), float32(math.Inf(-1)), float64(math.NaN()),
[]float32{1, float32(math.Inf(1))},
[]float64{math.NaN()},
} {
if !omitValue(omit) {
t.Errorf("non-finite %#v kept; json.Marshal would fail the whole file", omit)
}
}
}
func TestGGUFMetadataKeysIncludesOmitted(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
big := make([]string, ggufMetadataMaxArray+1)
path, _ := createBinFile(t, gguftest.KV{
"general.architecture": "llama",
"llama.block_count": uint32(4),
"tokenizer.ggml.tokens": big,
}, nil)
md, err := extractGGUFMetadata(path)
if err != nil {
t.Fatal(err)
}
keys := md.Keys()
for _, want := range []string{"general.architecture", "llama.block_count", "tokenizer.ggml.tokens"} {
if !slices.Contains(keys, want) {
t.Errorf("Keys() = %v, missing %s", keys, want)
}
}
}
// Projector audio detection reads through Keys(), including prefixed forms.
func TestProjectorAudioFromMetadata(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
for _, tt := range []struct {
name string
kv gguftest.KV
audio bool
}{
{"bare key", gguftest.KV{"general.architecture": "clip", "has_audio_encoder": true}, true},
{"prefixed key", gguftest.KV{"general.architecture": "clip", "clip.has_audio_encoder": true}, true},
{"present but false", gguftest.KV{"general.architecture": "clip", "has_audio_encoder": false}, false},
{"absent", gguftest.KV{"general.architecture": "clip"}, false},
} {
t.Run(tt.name, func(t *testing.T) {
path, _ := createBinFile(t, tt.kv, nil)
md, err := extractGGUFMetadata(path)
if err != nil {
t.Fatal(err)
}
if got := projectorHasAudio(md); got != tt.audio {
t.Errorf("projectorHasAudio = %v, want %v", got, tt.audio)
}
})
}
path, _ := createBinFile(t, gguftest.KV{
"general.architecture": "clip",
"has_audio_encoder": true,
"clip.vision.projector_type": "gemma3nv",
}, nil)
md, err := extractGGUFMetadata(path)
if err != nil {
t.Fatal(err)
}
if !projectorSuppressesAudioCapability(md) {
t.Error("gemma3nv projector should suppress audio")
}
}
func TestGGUFMetadataPathRejectsBadDigest(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
hex := "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
for _, digest := range []string{"", "sha256-short", "md5:" + hex, "../escape", "sha256:" + hex + "x"} {
if _, err := ggufMetadataPath(digest); err == nil {
t.Errorf("ggufMetadataPath(%q) accepted", digest)
}
}
for _, digest := range []string{"sha256:" + hex, "sha256-" + hex} {
if _, err := ggufMetadataPath(digest); err != nil {
t.Errorf("ggufMetadataPath(%q) rejected: %v", digest, err)
}
}
}
// A file can carry no values at all, and every value it does carry can be one
// the omission rule drops. Either way what is left still says which keys the
// file had, so it has to be storable: treating it as corrupt would rescan the
// blob on every request forever.
func TestGGUFMetadataWithNothingToCopy(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
path := filepath.Join(t.TempDir(), "empty.gguf")
if err := os.WriteFile(path, ggufBytes(0, nil), 0o600); err != nil {
t.Fatal(err)
}
md, err := extractGGUFMetadata(path)
if err != nil {
t.Fatalf("a file with no metadata is still a file: %v", err)
}
if len(md.KV) != 0 {
t.Errorf("KV = %v, want empty", md.KV)
}
encoded, err := json.Marshal(md)
if err != nil {
t.Fatal(err)
}
if _, err := decodeGGUFMetadata(encoded); err != nil {
t.Errorf("written metadata cannot be read back, so it would be rescanned forever: %v", err)
}
}
const allocBudget = 1 << 20
func ggufBytes(kvCount uint64, extra []byte) []byte {
b := make([]byte, 0, 24+len(extra))
b = binary.LittleEndian.AppendUint32(b, 0x46554747) // GGUF
b = binary.LittleEndian.AppendUint32(b, 3) // version
b = binary.LittleEndian.AppendUint64(b, 0) // tensor count
b = binary.LittleEndian.AppendUint64(b, kvCount) // kv count
return append(b, extra...)
}
func declaredGGUF(t *testing.T, kvCount uint64, extra []byte) string {
t.Helper()
b := ggufBytes(kvCount, extra)
path := filepath.Join(t.TempDir(), "declared.gguf")
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatal(err)
}
t.Logf("file is %d bytes, declares %d KV entries", len(b), kvCount)
return path
}
func allocDelta(t *testing.T, fn func()) uint64 {
t.Helper()
var before, after runtime.MemStats
runtime.GC()
runtime.ReadMemStats(&before)
fn()
runtime.ReadMemStats(&after)
return after.TotalAlloc - before.TotalAlloc
}
func TestGGUFMetadataBoundsDeclaredKVCount(t *testing.T) {
path := declaredGGUF(t, 1<<20, nil)
got := allocDelta(t, func() {
if _, err := extractGGUFMetadata(path); err == nil {
t.Error("expected an error from a 24-byte file")
}
})
t.Logf("allocated %d bytes before failing", got)
if got > allocBudget {
t.Errorf("allocated %d bytes from a 24-byte file, want at most %d", got, allocBudget)
}
}
// Cover every array element type because strings and fixed-width values use
// separate readers.
func TestGGUFMetadataBoundsDeclaredArray(t *testing.T) {
for _, tt := range []struct {
name string
elemType uint32
}{
{"uint8", 0},
{"int8", 1},
{"uint16", 2},
{"int16", 3},
{"uint32", 4},
{"int32", 5},
{"float32", 6},
{"bool", 7},
{"string", 8},
{"uint64", 10},
{"int64", 11},
{"float64", 12},
} {
t.Run(tt.name, func(t *testing.T) {
var extra []byte
extra = binary.LittleEndian.AppendUint64(extra, 3) // key length
extra = append(extra, "big"...)
extra = binary.LittleEndian.AppendUint32(extra, 9) // type: array
extra = binary.LittleEndian.AppendUint32(extra, tt.elemType)
extra = binary.LittleEndian.AppendUint64(extra, 8_000_000) // element count
path := declaredGGUF(t, 1, extra)
got := allocDelta(t, func() {
if _, err := extractGGUFMetadata(path); err == nil {
t.Error("expected an error")
}
})
if got > allocBudget {
t.Errorf("allocated %d bytes for a declared array of 8000000, want at most %d", got, allocBudget)
}
})
}
}
func removeBlob(t *testing.T, digest string) error {
t.Helper()
path, err := manifest.BlobsPath(digest)
if err != nil {
t.Fatal(err)
}
return os.Remove(path)
}
func metadataCount(t *testing.T) int {
t.Helper()
entries, err := os.ReadDir(filepath.Join(envconfig.Models(), "metadata"))
if err != nil {
return 0
}
return len(entries)
}
// createModelFromBlob and deleteModelNamed drive the handlers rather than
// writing manifests, so the metadata hooks under test are the ones the server
// actually runs.
func createModelFromBlob(t *testing.T, name, digest, tmpl string) {
t.Helper()
var s Server
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: name, Files: map[string]string{"model.gguf": digest},
Template: tmpl, Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("create %s: %d %s", name, w.Code, w.Body.String())
}
}
func deleteModelNamed(t *testing.T, name string) {
t.Helper()
var s Server
w := createRequest(t, s.DeleteHandler, api.DeleteRequest{Model: name})
if w.Code != http.StatusOK {
t.Fatalf("delete %s: %d %s", name, w.Code, w.Body.String())
}
}
// Metadata is derived from a blob, so it lives exactly as long as the blob: it
// survives while any manifest still references it and goes with the last one.
func TestGGUFMetadataRemovedWithLastReference(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("OLLAMA_MODELS", t.TempDir())
_, digest := createBinFile(t, gguftest.KV{
"general.architecture": "llama",
"llama.block_count": uint32(1),
}, nil)
createModelFromBlob(t, "alias-one", digest, "")
createModelFromBlob(t, "alias-two", digest, "")
if _, err := readGGUFMetadata(digest); err != nil {
t.Fatal(err)
}
if got := metadataCount(t); got != 1 {
t.Fatalf("metadata files = %d, want 1 (both aliases share one blob)", got)
}
deleteModelNamed(t, "alias-one")
if got := metadataCount(t); got != 1 {
t.Fatalf("metadata files after removing one alias = %d, want 1", got)
}
deleteModelNamed(t, "alias-two")
if got := metadataCount(t); got != 0 {
t.Fatalf("metadata files after removing the last reference = %d, want 0", got)
}
}
// Recreating a model over the same name replaces its blob; the old blob's
// metadata must go with it.
func TestGGUFMetadataRemovedOnReplacement(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("OLLAMA_MODELS", t.TempDir())
_, first := createBinFile(t, gguftest.KV{
"general.architecture": "llama",
"llama.block_count": uint32(1),
}, nil)
createModelFromBlob(t, "replace-me", first, "")
if _, err := readGGUFMetadata(first); err != nil {
t.Fatal(err)
}
_, second := createBinFile(t, gguftest.KV{
"general.architecture": "llama",
"llama.block_count": uint32(2),
}, nil)
createModelFromBlob(t, "replace-me", second, "")
if _, err := readGGUFMetadata(second); err != nil {
t.Fatal(err)
}
if got := metadataCount(t); got != 1 {
t.Fatalf("metadata files after replacement = %d, want 1 (the old blob's should be gone)", got)
}
old, err := ggufMetadataPath(first)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Errorf("metadata for the replaced blob still present: %v", err)
}
}
// A blob missing from disk is still a blob whose metadata has to go. It no
// longer appears in the blob scan, so pruning has to find its metadata directly.
func TestGGUFMetadataRemovedForMissingBlob(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
_, orphan := createBinFile(t, gguftest.KV{"general.architecture": "llama"}, nil)
if _, err := readGGUFMetadata(orphan); err != nil {
t.Fatal(err)
}
if err := removeBlob(t, orphan); err != nil {
t.Fatal(err)
}
_, live := createBinFile(t, gguftest.KV{"general.architecture": "bert"}, nil)
if _, err := readGGUFMetadata(live); err != nil {
t.Fatal(err)
}
if got := metadataCount(t); got != 2 {
t.Fatalf("metadata files before the sweep = %d, want 2", got)
}
if err := PruneLayers(); err != nil {
t.Fatal(err)
}
if got := metadataCount(t); got != 1 {
t.Errorf("metadata files = %d, want 1", got)
}
path, err := ggufMetadataPath(live)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Errorf("metadata for existing blob was removed: %v", err)
}
}
func TestGGUFMetadataNotPublishedAfterDelete(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
// Deletion and publication can finish in either order. The deletion tests
// above cover metadata that already exists; put deletion first here to
// deterministically cover the writer's post-publication blob check.
_, digest := createBinFile(t, gguftest.KV{"general.architecture": "llama"}, nil)
blob, err := manifest.BlobsPath(digest)
if err != nil {
t.Fatal(err)
}
if err := os.Remove(blob); err != nil {
t.Fatal(err)
}
path, err := ggufMetadataPath(digest)
if err != nil {
t.Fatal(err)
}
writeGGUFMetadata(path, blob, ggufMetadata{KV: map[string]any{
"general.architecture": "llama",
}})
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("metadata published after model deletion: %v", err)
}
}
// The point of the metadata file is that loading a model no longer reads its
// blob. With the blob deleted, GetModel must still describe the model exactly
// as before - which is also the guard against anything reintroducing a
// per-request read of the model file.
func TestGetModelReadsNoBlob(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("OLLAMA_MODELS", t.TempDir())
_, digest := createBinFile(t, gguftest.KV{
"general.architecture": "bert",
"bert.pooling_type": uint32(1),
"bert.context_length": uint32(512),
"bert.embedding_length": uint32(384),
}, nil)
createModelFromBlob(t, "embedder", digest, "")
want, err := GetModel("embedder")
if err != nil {
t.Fatal(err)
}
if !slices.Contains(want.Capabilities(), model.CapabilityEmbedding) {
t.Fatalf("capabilities = %v, want embedding", want.Capabilities())
}
if err := removeBlob(t, digest); err != nil {
t.Fatal(err)
}
got, err := GetModel("embedder")
if err != nil {
t.Fatalf("GetModel needed the blob: %v", err)
}
if !slices.Equal(got.Capabilities(), want.Capabilities()) {
t.Errorf("capabilities without the blob = %v, want %v", got.Capabilities(), want.Capabilities())
}
if got.metadata.Int("context_length") != 512 {
t.Errorf("context_length = %d, want 512", got.metadata.Int("context_length"))
}
}
// Extraction either fails or produces metadata that survives a metadata-file
// round trip unchanged.
func FuzzGGUFMetadata(f *testing.F) {
f.Add(ggufBytes(0, nil))
f.Add(ggufBytes(1<<20, nil))
var str []byte
str = binary.LittleEndian.AppendUint64(str, 20)
str = append(str, "general.architecture"...)
str = binary.LittleEndian.AppendUint32(str, 8) // type: string
str = binary.LittleEndian.AppendUint64(str, 5)
str = append(str, "llama"...)
f.Add(ggufBytes(1, str))
var arr []byte
arr = binary.LittleEndian.AppendUint64(arr, 3)
arr = append(arr, "big"...)
arr = binary.LittleEndian.AppendUint32(arr, 9) // type: array
arr = binary.LittleEndian.AppendUint32(arr, 8) // element type: string
arr = binary.LittleEndian.AppendUint64(arr, 8_000_000)
f.Add(ggufBytes(1, arr))
path := filepath.Join(f.TempDir(), "fuzz.gguf")
f.Fuzz(func(t *testing.T, data []byte) {
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
md, err := extractGGUFMetadata(path)
if err != nil {
return
}
if md.KV == nil {
t.Fatal("extraction reported success with no metadata")
}
encoded, err := json.Marshal(md)
if err != nil {
t.Fatalf("extracted metadata cannot be written: %v", err)
}
loaded, err := decodeGGUFMetadata(encoded)
if err != nil {
t.Fatalf("extracted metadata cannot be read back: %v", err)
}
if !reflect.DeepEqual(md, loaded) {
t.Errorf("metadata changed on the trip through a file:\n%#v\n%#v", md, loaded)
}
})
}