Files
ollama/server/routes_create_test.go
Daniel Hiltgen 9db4bdbad6 runner: Remove CGO engines, use llama-server exclusively for GGML models (#16031)
* broad lint fixes to sidestep CI scope glitch

* runner: Remove CGO engines, use llama-server exclusively for GGML models

Remove the vendored GGML and llama.cpp backend, CGO runner, Go model
implementations, and sample.  llama-server (built from upstream llama.cpp via
FetchContent) is now the sole inference engine for GGUF-based models.
(Safetensor based models continue to run on the new MLX engine.)  This allows
us to more rapidly pick up new capabilities and fixes from llama.cpp as they
come out.

On windows this now requires recent AMD driver versions to support ROCm v7 as
llama.cpp currently does not support building against v6.

* llama/compat: load Ollama-format GGUFs in llama-server

Squashed from upstream/jmorganca/llama-compat on 2026-04-29.
Source tip: 0c33775d378511a9b3c7f2e3b80eda355511d9f3.

Original source commits:
- 25223160d llama/compat: add in-memory shim so llama-server can load Ollama-format GGUFs
- 7449b539a llm,server: route Ollama-format gemma3 blobs through llama/compat
- 436f2e2b1 llama/compat: make patch-apply idempotent
- 8c2c9d4c8 llama/compat: extend gemma3 handler to cover 1B and 270M blobs
- 021389f7b llama/compat: shrink clip.cpp injection from 18 lines to 1
- 61b367ec2 llama/compat: shrink patch to pure call-site hooks (34 -> 20 lines)
- 36049361c llama/compat: simplify shim (gemma3-tested)
- 8fa664865 llama/compat: add qwen35moe text handler
- db0c74530 llama/compat: add qwen35moe vision (clip) support
- 2a388da77 llama/compat: split shared infra into a util TU
- 9a69a17dc llama/compat: document non-public API dependencies
- d0f38a915 llama/compat: add gpt-oss and lfm2 handlers
- 086071822 llama/compat: add mistral3 text handler (vision TODO)
- 63bde9ff7 llama/compat: add mistral3 vision (clip) support
- 3a57b89d5 llama/compat: apply LLaMA RoPE permute to mistral3 vision Q/K
- 99cb87439 llama/compat: add qwen35, gemma4, deepseek-ocr handlers
- 2c7850dba llama/compat: add nemotron_h_moe handler (latent FFN + MTP skip)
- 9e3b54225 llama/compat: add llama4 text + clip handlers
- 034fee349 llama/compat: add gemma4 clip handler (gemma4v projector)
- 9945c5a93 server: remove dhiltgen/* compat redirect table
- 5d4539101 llama/compat: rewrite gemma4 tokenizer model to BPE
- 7e0765327 llama/compat: add glm-ocr text handler + text-loader load-op hook
- f1bd1a25a llama/compat: add glm-ocr clip handler (glm4v projector)
- 4b5cf3420 llama/compat: collapse text-loader hook back to one new patch line
- eb4ecf4fc llama/compat: extend gemma4 clip handler to gemma4a (audio)
- a23a5e76f llama/compat: fix gemma4a per-block norm tensor mapping
- cd2dcaff4 llama/compat: add embeddinggemma handler
- 1ce8a6b26 llama/compat: add qwen3-vl + qwen2.5-vl handlers
- fd98ffa1e llama/compat: add gemma3n + glm4moelite handlers
- cc7bdf0bc llama/compat: handle null buft in maybe_load_tensor
- 0c33775d3 llama/compat: disable mmap when load_op transforms text-side tensors

* refine implementation

* ci: fix windows MLX build

* ci: fix windows llama-server build

* ci: fix windows rocm build

* ci: windows mlx tuning

Shorten long-tail on build, and get OllamaSetup.exe back under 2g limit

* ci: fix windows dependencies

* win: fix dependency gathering

* disable openmp

* win: arm64 cross-compile build

also DRY out CI steps

* scheduler improvements

* ci: improvements from #15982

* win: favor ninja for faster developer builds

* win: fix build

* win: fix arm64 cross-compile

* win: avoid spaces in compiler path

* misc discovery fixes, and bos handling

* lint fixes

* win: fix arm cross-compile build/CI bugs

* llama.cpp update

* win: handle multiple CRT dirs

* vulkan: add windows iGPU detection

* fix creation bugs for patched models, other refactoring work

* tune batch size for better performance

* ci and lint fixes

* fix repeat_last_n bug

* build: revamp build for better developer UX

* amd, sampler, qwen3next fixes

* version bump

* fix mlx build

* revamp GPU discovery

Scanning the output of llama-server is turning out to be too error prone across
llama.cpp updates, so this switches to a thin dynamic library load against the
bundled GGML libraries so more details can be gathered from the API.

* version bump

* missing file

* ci: fix cache miss on rocm build

* refine vulkan dep handling

* fix ps reporting bug on full GPU load

* improve cmake wiring for customized local builds

* version bump

* docker build arg cleanup

* improve windows exit error logs

* fix community gemma4 support and ci flakes

* fix mlx unit test

* tighten up ps logic to avoid double counting fit log lines

* version bump

* fix ps view for full gpu layer offload

* add MTP wiring for llama-server and create with GGUFs

* pick best template by capabilities

* version bump

* ci: harden apt repos

* remove unused cpu core discovery

* adjust batch default logic to reduce OOMs

* support larger tool calls

* fix audio support, template show

* qwen35 mtp patch support

* flesh out dtypes

* rocm deps

* version bump

* lint fix

* block broken gfx1150 on windows

* fix qwen3.5 moe mtp tensors in patch

* mmproj oom fallback and vulkan on by default

* qwen MTP compat fix

* version bump

* ci: fix WoA cross-compile

* ci: workaround ui tool in cross-compile

* version bump

* win: enable OpenMP for CPU builds

* build: improve developer UX

* ci: windows path workaround for CPU build

* win: fix WoA dependencies

* win: fix large offset reads for mmproj patched loads

* version bump

* fix vulkan dup detection

* add OLLAMA_IGPU_ENABLE and largely disable iGPUs by default

* opt-in MTP, win large offset, integraton fixes

* fix unit test scheduler interaction hang

* fix multi-gpu filtering

* version bump

* review comments

* fix thinking level

* fix linux rocm ordering and granite 3.3 template

* version bump

* ci fix - non-shallow MLX checkout

* bypass linux sysfs unit test on windows

---------

Co-authored-by: jmorganca <jmorganca@gmail.com>
2026-05-29 13:35:47 -07:00

2018 lines
57 KiB
Go

package server
import (
"bytes"
"cmp"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"github.com/gin-gonic/gin"
gocmp "github.com/google/go-cmp/cmp"
gocmpopts "github.com/google/go-cmp/cmp/cmpopts"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/convert"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/fs/ggml"
"github.com/ollama/ollama/manifest"
"github.com/ollama/ollama/types/model"
)
var stream bool = false
func createBinFile(t *testing.T, kv map[string]any, ti []*ggml.Tensor) (string, string) {
t.Helper()
t.Setenv("OLLAMA_MODELS", cmp.Or(os.Getenv("OLLAMA_MODELS"), t.TempDir()))
modelDir := envconfig.Models()
f, err := os.CreateTemp(t.TempDir(), "")
if err != nil {
t.Fatal(err)
}
defer f.Close()
var base convert.KV = map[string]any{"general.architecture": "test"}
maps.Copy(base, kv)
if err := ggml.WriteGGUF(f, base, ti); err != nil {
t.Fatal(err)
}
// Calculate sha256 of file
if _, err := f.Seek(0, 0); err != nil {
t.Fatal(err)
}
digest, _ := GetSHA256Digest(f)
if err := f.Close(); err != nil {
t.Fatal(err)
}
if err := createLink(f.Name(), filepath.Join(modelDir, "blobs", fmt.Sprintf("sha256-%s", strings.TrimPrefix(digest, "sha256:")))); err != nil {
t.Fatal(err)
}
return f.Name(), digest
}
type responseRecorder struct {
*httptest.ResponseRecorder
http.CloseNotifier
}
func NewRecorder() *responseRecorder {
return &responseRecorder{
ResponseRecorder: httptest.NewRecorder(),
}
}
func (t *responseRecorder) CloseNotify() <-chan bool {
return make(chan bool)
}
func createRequest(t *testing.T, fn func(*gin.Context), body any) *httptest.ResponseRecorder {
t.Helper()
// if OLLAMA_MODELS is not set, set it to the temp directory
t.Setenv("OLLAMA_MODELS", cmp.Or(os.Getenv("OLLAMA_MODELS"), t.TempDir()))
w := NewRecorder()
c, _ := gin.CreateTestContext(w)
var b bytes.Buffer
if err := json.NewEncoder(&b).Encode(body); err != nil {
t.Fatal(err)
}
c.Request = &http.Request{
Body: io.NopCloser(&b),
}
fn(c)
return w.ResponseRecorder
}
func readCreatedModelConfig(t *testing.T, name string) model.ConfigV2 {
t.Helper()
mf, err := manifest.ParseNamedManifest(model.ParseName(name))
if err != nil {
t.Fatalf("parse manifest: %v", err)
}
if mf.Config.Digest == "" {
t.Fatalf("unexpected empty config digest for manifest")
}
configPath, err := manifest.BlobsPath(mf.Config.Digest)
if err != nil {
t.Fatalf("config blob path: %v", err)
}
cfgFile, err := os.Open(configPath)
if err != nil {
t.Fatalf("open config blob: %v", err)
}
defer cfgFile.Close()
var cfg model.ConfigV2
if err := json.NewDecoder(cfgFile).Decode(&cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
return cfg
}
func TestCreateModelPreservesEmbeddedCompatibilityGGUFWithoutQuantization(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
oldRun := runLlamaQuantize
runLlamaQuantize = func(in, out *os.File, orig *ggml.GGML, fileType ggml.FileType, typeName string, progressFn func(uint64)) error {
t.Fatal("llama-quantize should not run for GGUFs with embedded compatibility tensors")
return nil
}
t.Cleanup(func() {
runLlamaQuantize = oldRun
})
_, digest := createBinFile(t, map[string]any{
"general.architecture": "gemma4",
"general.file_type": uint32(ggml.FileTypeF32),
}, []*ggml.Tensor{
{
Name: "v.patch_embd.weight",
Kind: uint32(ggml.TensorTypeF32),
Shape: []uint64{1, 1},
WriterTo: bytes.NewReader(make([]byte, 4)),
},
})
baseLayers, err := ggufLayers(digest, "test.gguf", func(api.ProgressResponse) {})
if err != nil {
t.Fatal(err)
}
name := model.ParseName("test-create-preserve-gguf:latest")
config := &model.ConfigV2{
OS: "linux",
Architecture: "amd64",
RootFS: model.RootFS{Type: "layers"},
}
req := api.CreateRequest{Model: name.String()}
if err := createModel(req, name, baseLayers, config, func(api.ProgressResponse) {}); err != nil {
t.Fatal(err)
}
mf, err := manifest.ParseNamedManifest(name)
if err != nil {
t.Fatal(err)
}
var found bool
for _, layer := range mf.Layers {
if layer.MediaType == "application/vnd.ollama.image.model" {
found = true
}
}
if !found {
t.Fatal("created manifest is missing a model layer")
}
if mf.Layers[0].Digest != digest {
t.Fatalf("model layer digest = %q, want original digest %q", mf.Layers[0].Digest, digest)
}
}
func TestCreateModelValidatesTextOnlyFileGGUFWithoutQuantization(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
var gotTypeName string
oldRun := runLlamaQuantize
runLlamaQuantize = func(in, out *os.File, orig *ggml.GGML, fileType ggml.FileType, typeName string, progressFn func(uint64)) error {
gotTypeName = typeName
return copyLlamaQuantizeInput(in, out, orig, fileType, typeName, progressFn)
}
t.Cleanup(func() {
runLlamaQuantize = oldRun
})
_, digest := createBinFile(t, map[string]any{
"general.architecture": "llama",
"general.file_type": uint32(ggml.FileTypeF32),
}, []*ggml.Tensor{
{
Name: "blk.0.attn_q.weight",
Kind: uint32(ggml.TensorTypeF32),
Shape: []uint64{1, 1},
WriterTo: bytes.NewReader(make([]byte, 4)),
},
})
baseLayers, err := ggufLayers(digest, "test.gguf", func(api.ProgressResponse) {})
if err != nil {
t.Fatal(err)
}
name := model.ParseName("test-create-validate-gguf:latest")
config := &model.ConfigV2{
OS: "linux",
Architecture: "amd64",
RootFS: model.RootFS{Type: "layers"},
}
req := api.CreateRequest{Model: name.String()}
if err := createModel(req, name, baseLayers, config, func(api.ProgressResponse) {}); err != nil {
t.Fatal(err)
}
if gotTypeName != "COPY" {
t.Fatalf("llama-quantize type = %q, want COPY", gotTypeName)
}
}
func TestCreateModelValidatesSplitGGUFWithOriginalShardNames(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
var gotInput string
oldRun := runLlamaQuantize
runLlamaQuantize = func(in, out *os.File, orig *ggml.GGML, fileType ggml.FileType, typeName string, progressFn func(uint64)) error {
gotInput = in.Name()
if typeName != "COPY" {
t.Fatalf("llama-quantize type = %q, want COPY", typeName)
}
if got := filepath.Base(in.Name()); got != "model-00001-of-00002.gguf" {
t.Fatalf("llama-quantize input = %q, want first split shard name", got)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(in.Name()), "model-00002-of-00002.gguf")); err != nil {
t.Fatalf("missing linked second split shard: %v", err)
}
return ggml.WriteGGUF(out, ggml.KV{
"general.architecture": "llama",
"general.file_type": fileType,
}, []*ggml.Tensor{
{
Name: "blk.0.attn_q.weight",
Kind: uint32(ggml.TensorTypeF32),
Shape: []uint64{1, 1},
WriterTo: bytes.NewReader(make([]byte, 4)),
},
})
}
t.Cleanup(func() {
runLlamaQuantize = oldRun
})
_, firstDigest := createBinFile(t, ggml.KV{
"general.architecture": "llama",
"general.file_type": uint32(ggml.FileTypeF32),
"llama.split.no": uint32(0),
"llama.split.count": uint32(2),
"llama.split.tensors.count": int32(2),
}, []*ggml.Tensor{
{
Name: "blk.0.attn_q.weight",
Kind: uint32(ggml.TensorTypeF32),
Shape: []uint64{1, 1},
WriterTo: bytes.NewReader(make([]byte, 4)),
},
})
_, secondDigest := createBinFile(t, ggml.KV{
"general.architecture": "llama",
"general.file_type": uint32(ggml.FileTypeF32),
"llama.split.no": uint32(1),
"llama.split.count": uint32(2),
"llama.split.tensors.count": int32(2),
}, []*ggml.Tensor{
{
Name: "blk.1.attn_q.weight",
Kind: uint32(ggml.TensorTypeF32),
Shape: []uint64{1, 1},
WriterTo: bytes.NewReader(make([]byte, 4)),
},
})
baseLayers, err := convertModelFromFiles(map[string]string{
"model-00001-of-00002.gguf": firstDigest,
"model-00002-of-00002.gguf": secondDigest,
}, nil, false, func(api.ProgressResponse) {})
if err != nil {
t.Fatal(err)
}
name := model.ParseName("test-create-split-gguf:latest")
config := &model.ConfigV2{
OS: "linux",
Architecture: "amd64",
RootFS: model.RootFS{Type: "layers"},
}
req := api.CreateRequest{Model: name.String()}
if err := createModel(req, name, baseLayers, config, func(api.ProgressResponse) {}); err != nil {
t.Fatal(err)
}
if gotInput == "" {
t.Fatal("llama-quantize was not invoked")
}
mf, err := manifest.ParseNamedManifest(name)
if err != nil {
t.Fatal(err)
}
var modelLayers int
for _, layer := range mf.Layers {
if layer.MediaType == "application/vnd.ollama.image.model" {
modelLayers++
}
}
if modelLayers != 1 {
t.Fatalf("model layer count = %d, want 1", modelLayers)
}
}
func TestBaseLayerTensorsReadsAllSplitGGUFShards(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
firstData := []byte{1, 2, 3, 4}
secondData := []byte{5, 6, 7, 8}
_, firstDigest := createBinFile(t, ggml.KV{
"general.architecture": "qwen35",
"general.file_type": uint32(ggml.FileTypeF32),
"qwen35.split.no": uint32(0),
"qwen35.split.count": uint32(2),
"qwen35.split.tensors.count": int32(2),
}, []*ggml.Tensor{
{
Name: "blk.0.attn_q.weight",
Kind: uint32(ggml.TensorTypeF32),
Shape: []uint64{1},
WriterTo: bytes.NewReader(firstData),
},
})
_, secondDigest := createBinFile(t, ggml.KV{
"general.architecture": "qwen35",
"general.file_type": uint32(ggml.FileTypeF32),
"qwen35.split.no": uint32(1),
"qwen35.split.count": uint32(2),
"qwen35.split.tensors.count": int32(2),
}, []*ggml.Tensor{
{
Name: "blk.1.attn_q.weight",
Kind: uint32(ggml.TensorTypeF32),
Shape: []uint64{1},
WriterTo: bytes.NewReader(secondData),
},
})
baseLayers, err := convertModelFromFiles(map[string]string{
"model-00001-of-00002.gguf": firstDigest,
"model-00002-of-00002.gguf": secondDigest,
}, nil, false, func(api.ProgressResponse) {})
if err != nil {
t.Fatal(err)
}
if got, want := len(baseLayers), 1; got != want {
t.Fatalf("base layers = %d, want %d", got, want)
}
tensors, cleanup, err := baseLayerTensors(baseLayers[0])
if err != nil {
t.Fatal(err)
}
defer cleanup()
byName := map[string][]byte{}
for _, tensor := range tensors {
var b bytes.Buffer
if _, err := tensor.WriteTo(&b); err != nil {
t.Fatal(err)
}
byName[tensor.Name] = b.Bytes()
}
if got := byName["blk.0.attn_q.weight"]; !bytes.Equal(got, firstData) {
t.Fatalf("first shard tensor data = %v, want %v", got, firstData)
}
if got := byName["blk.1.attn_q.weight"]; !bytes.Equal(got, secondData) {
t.Fatalf("second shard tensor data = %v, want %v", got, secondData)
}
}
func TestCreateModelAddsDefaultLlavaProjectorType(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
_, digest := createBinFile(t, map[string]any{
"general.architecture": "clip",
"clip.has_vision_encoder": true,
"clip.vision.block_count": uint32(1),
}, []*ggml.Tensor{
{
Name: "mm.0.weight",
Kind: uint32(ggml.TensorTypeF32),
Shape: []uint64{1, 1},
WriterTo: bytes.NewReader(make([]byte, 4)),
},
})
baseLayers, err := ggufLayers(digest, "test.gguf", func(api.ProgressResponse) {})
if err != nil {
t.Fatal(err)
}
name := model.ParseName("test-create-llava-projector:latest")
config := &model.ConfigV2{
OS: "linux",
Architecture: "amd64",
RootFS: model.RootFS{Type: "layers"},
}
req := api.CreateRequest{Model: name.String()}
if err := createModel(req, name, baseLayers, config, func(api.ProgressResponse) {}); err != nil {
t.Fatal(err)
}
mf, err := manifest.ParseNamedManifest(name)
if err != nil {
t.Fatal(err)
}
var projectorLayer manifest.Layer
for _, layer := range mf.Layers {
if layer.MediaType == "application/vnd.ollama.image.projector" {
projectorLayer = layer
break
}
}
if projectorLayer.Digest == "" {
t.Fatal("created manifest is missing a projector layer")
}
if projectorLayer.Digest == digest {
t.Fatal("projector layer was not rewritten")
}
blobPath, err := manifest.BlobsPath(projectorLayer.Digest)
if err != nil {
t.Fatal(err)
}
blob, err := os.Open(blobPath)
if err != nil {
t.Fatal(err)
}
defer blob.Close()
projector, err := ggml.Decode(blob, -1)
if err != nil {
t.Fatal(err)
}
if got := projector.KV().String("projector_type"); got != "mlp" {
t.Fatalf("clip.projector_type = %q, want mlp", got)
}
}
func TestCreateModelQuantizeRestoresEmbeddedCompatibilityTensors(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
oldRun := runLlamaQuantize
runLlamaQuantize = func(in, out *os.File, orig *ggml.GGML, fileType ggml.FileType, typeName string, progressFn func(uint64)) error {
kv := ggml.KV{
"general.architecture": "qwen2vl",
"general.file_type": fileType,
"qwen25vl.attention.head_count": uint32(1),
"qwen25vl.attention.head_count_kv": uint32(1),
}
return ggml.WriteGGUF(out, kv, []*ggml.Tensor{
{
Name: "blk.0.attn_q.weight",
Kind: uint32(ggml.TensorTypeQ4_K),
Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(make([]byte, int(ggml.TensorTypeQ4_K.RowSize(256)))),
},
})
}
t.Cleanup(func() {
runLlamaQuantize = oldRun
})
_, digest := createBinFile(t, map[string]any{
"general.architecture": "qwen25vl",
"general.file_type": uint32(ggml.FileTypeF16),
"qwen25vl.attention.head_count": uint32(8),
"qwen25vl.attention.head_count_kv": uint32(2),
}, []*ggml.Tensor{
{
Name: "blk.0.attn_q.weight",
Kind: uint32(ggml.TensorTypeF16),
Shape: []uint64{256, 1},
WriterTo: bytes.NewReader(make([]byte, 512)),
},
{
Name: "v.patch_embd.weight",
Kind: uint32(ggml.TensorTypeF16),
Shape: []uint64{1, 1},
WriterTo: bytes.NewReader(make([]byte, 2)),
},
})
baseLayers, err := ggufLayers(digest, "test.gguf", func(api.ProgressResponse) {})
if err != nil {
t.Fatal(err)
}
name := model.ParseName("test-create-quantize-embedded-gguf:latest")
config := &model.ConfigV2{
OS: "linux",
Architecture: "amd64",
RootFS: model.RootFS{Type: "layers"},
}
req := api.CreateRequest{Model: name.String(), Quantize: "Q4_K_M"}
if err := createModel(req, name, baseLayers, config, func(api.ProgressResponse) {}); err != nil {
t.Fatal(err)
}
mf, err := manifest.ParseNamedManifest(name)
if err != nil {
t.Fatal(err)
}
var modelLayer manifest.Layer
for _, layer := range mf.Layers {
if layer.MediaType == "application/vnd.ollama.image.model" {
modelLayer = layer
break
}
}
if modelLayer.Digest == "" {
t.Fatal("created manifest is missing a model layer")
}
blobPath, err := manifest.BlobsPath(modelLayer.Digest)
if err != nil {
t.Fatal(err)
}
blob, err := os.Open(blobPath)
if err != nil {
t.Fatal(err)
}
defer blob.Close()
f, err := ggml.Decode(blob, -1)
if err != nil {
t.Fatal(err)
}
byName := map[string]*ggml.Tensor{}
for _, tensor := range f.Tensors().Items() {
byName[tensor.Name] = tensor
}
if got := f.KV().FileType(); got != ggml.FileTypeQ4_K_M {
t.Fatalf("file type = %s, want Q4_K_M", got)
}
if got := f.KV().Architecture(); got != "qwen25vl" {
t.Fatalf("architecture = %q, want qwen25vl", got)
}
if got, _ := f.KV().Value("qwen25vl.attention.head_count").(uint32); got != 8 {
t.Fatalf("attention head count = %d, want 8", got)
}
if got := ggml.TensorType(byName["blk.0.attn_q.weight"].Kind); got != ggml.TensorTypeQ4_K {
t.Fatalf("text tensor type = %s, want Q4_K", got)
}
if got := ggml.TensorType(byName["v.patch_embd.weight"].Kind); got != ggml.TensorTypeF16 {
t.Fatalf("embedded tensor type = %s, want F16", got)
}
}
func TestCreateModelRejectsFileGGUFWhenValidationFails(t *testing.T) {
t.Setenv("OLLAMA_MODELS", t.TempDir())
oldRun := runLlamaQuantize
runLlamaQuantize = func(*os.File, *os.File, *ggml.GGML, ggml.FileType, string, func(uint64)) error {
return fmt.Errorf("load failed")
}
t.Cleanup(func() {
runLlamaQuantize = oldRun
})
_, digest := createBinFile(t, map[string]any{
"general.architecture": "llama",
"general.file_type": uint32(ggml.FileTypeF32),
}, []*ggml.Tensor{
{
Name: "blk.0.attn_q.weight",
Kind: uint32(ggml.TensorTypeF32),
Shape: []uint64{1, 1},
WriterTo: bytes.NewReader(make([]byte, 4)),
},
})
baseLayers, err := ggufLayers(digest, "test.gguf", func(api.ProgressResponse) {})
if err != nil {
t.Fatal(err)
}
name := model.ParseName("test-create-reject-gguf:latest")
config := &model.ConfigV2{
OS: "linux",
Architecture: "amd64",
RootFS: model.RootFS{Type: "layers"},
}
req := api.CreateRequest{Model: name.String()}
err = createModel(req, name, baseLayers, config, func(api.ProgressResponse) {})
if err == nil {
t.Fatal("expected create to fail")
}
if !strings.Contains(err.Error(), "failed to validate GGUF with llama-quantize without compatibility patches") {
t.Fatalf("unexpected error: %v", err)
}
}
func checkFileExists(t *testing.T, p string, expect []string) {
t.Helper()
actual, err := filepath.Glob(p)
if err != nil {
t.Fatal(err)
}
if diff := gocmp.Diff(expect, actual, gocmpopts.SortSlices(strings.Compare), gocmpopts.EquateEmpty()); diff != "" {
t.Errorf("file exists mismatch (-want +got):\n%s", diff)
}
}
func TestCreateFromBin(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
fmt.Println(w)
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-6bcdb8859d417753645538d7bbfbd7ca91a3f0c191aef5379c53c05e86b669dd"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
})
t.Run("empty file digest", func(t *testing.T) {
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "my-gguf-model",
Files: map[string]string{"0.gguf": ""},
Stream: &stream,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "invalid digest format") {
t.Errorf("expected invalid digest format error, got:\n%s", w.Body.String())
}
})
t.Run("empty adapter digest", func(t *testing.T) {
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "my-gguf-model",
Files: map[string]string{"0.gguf": digest},
Adapters: map[string]string{"adapter.gguf": ""},
Stream: &stream,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "invalid digest format") {
t.Errorf("expected invalid digest format error, got:\n%s", w.Body.String())
}
})
}
func TestCreateFromModel(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
w = createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test2",
From: "test",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test2", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-6bcdb8859d417753645538d7bbfbd7ca91a3f0c191aef5379c53c05e86b669dd"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
})
}
func TestCreateFromModelInheritsRendererParser(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
const (
renderer = "custom-renderer"
parser = "custom-parser"
)
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "base",
Files: map[string]string{"base.gguf": digest},
Renderer: renderer,
Parser: parser,
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
w = createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "child",
From: "base",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
mf, err := manifest.ParseNamedManifest(model.ParseName("child"))
if err != nil {
t.Fatalf("parse manifest: %v", err)
}
if mf.Config.Digest == "" {
t.Fatalf("unexpected empty config digest for child manifest")
}
configPath, err := manifest.BlobsPath(mf.Config.Digest)
if err != nil {
t.Fatalf("config blob path: %v", err)
}
cfgFile, err := os.Open(configPath)
if err != nil {
t.Fatalf("open config blob: %v", err)
}
defer cfgFile.Close()
var cfg model.ConfigV2
if err := json.NewDecoder(cfgFile).Decode(&cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg.Renderer != renderer {
t.Fatalf("expected renderer %q, got %q", renderer, cfg.Renderer)
}
if cfg.Parser != parser {
t.Fatalf("expected parser %q, got %q", parser, cfg.Parser)
}
}
func TestCreateRemovesLayers(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Template: "{{ .Prompt }}",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
filepath.Join(p, "blobs", "sha256-b507b9c2f6ca642bffcd06665ea7c91f235fd32daeefdf875a0f938db05fb315"),
filepath.Join(p, "blobs", "sha256-f6e7e4b28e0b1d0c635f2d465bd248c5387c3e75b61a48c4374192b26d832a56"),
})
w = createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Template: "{{ .System }} {{ .Prompt }}",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-136bf7c76bac2ec09d6617885507d37829e04b41acc47687d45e512b544e893a"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
filepath.Join(p, "blobs", "sha256-fe7ac77b725cda2ccad03f88a880ecdfd7a33192d6cae08fce2c0ee1455991ed"),
})
}
func TestCreateUnsetsSystem(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
System: "Say hi!",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-0a666d113e8e0a3d27e9c7bd136a0bdfb6241037db50729d81568451ebfdbde8"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
filepath.Join(p, "blobs", "sha256-f29e82a8284dbdf5910b1555580ff60b04238b8da9d5e51159ada67a4d0d5851"),
})
w = createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
System: "",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-6bcdb8859d417753645538d7bbfbd7ca91a3f0c191aef5379c53c05e86b669dd"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
})
}
func TestCreateMergeParameters(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Parameters: map[string]any{
"temperature": 1,
"top_k": 10,
"stop": []string{"USER:", "ASSISTANT:"},
},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-1d0ad71299d48c2fb7ae2b98e683643e771f8a5b72be34942af90d97a91c1e37"),
filepath.Join(p, "blobs", "sha256-6d6e36c1f90fc7deefc33a7300aa21ad4b67c506e33ecdeddfafa98147e60bbf"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
})
// in order to merge parameters, the second model must be created FROM the first
w = createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test2",
From: "test",
Parameters: map[string]any{
"temperature": 0.6,
"top_p": 0.7,
},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test2", "latest"),
})
// Display contents of each blob in the directory
blobDir := filepath.Join(p, "blobs")
entries, err := os.ReadDir(blobDir)
if err != nil {
t.Fatalf("failed to read blobs directory: %v", err)
}
for _, entry := range entries {
blobPath := filepath.Join(blobDir, entry.Name())
content, err := os.ReadFile(blobPath)
if err != nil {
t.Fatalf("failed to read blob %s: %v", entry.Name(), err)
}
t.Logf("Contents of %s:\n%s", entry.Name(), string(content))
}
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-1d0ad71299d48c2fb7ae2b98e683643e771f8a5b72be34942af90d97a91c1e37"),
filepath.Join(p, "blobs", "sha256-6d6e36c1f90fc7deefc33a7300aa21ad4b67c506e33ecdeddfafa98147e60bbf"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
filepath.Join(p, "blobs", "sha256-bbdce269dabe013033632238b4b2d1e02fac2f97787c5e895f4da84e09cccd5d"),
filepath.Join(p, "blobs", "sha256-e29a7b3c47287a2489c895d21fe413c20f859a85d20e749492f52a838e36e1ba"),
})
actual, err := os.ReadFile(filepath.Join(p, "blobs", "sha256-e29a7b3c47287a2489c895d21fe413c20f859a85d20e749492f52a838e36e1ba"))
if err != nil {
t.Fatal(err)
}
expect, err := json.Marshal(map[string]any{"temperature": 0.6, "top_k": 10, "top_p": 0.7, "stop": []string{"USER:", "ASSISTANT:"}})
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(bytes.TrimSpace(expect), bytes.TrimSpace(actual)) {
t.Errorf("expected %s, actual %s", string(expect), string(actual))
}
// slices are replaced
w = createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test2",
From: "test",
Parameters: map[string]any{
"temperature": 0.6,
"top_p": 0.7,
"stop": []string{"<|endoftext|>"},
},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test2", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-12f58bb75cb3042d69a7e013ab87fb3c3c7088f50ddc62f0c77bd332f0d44d35"),
filepath.Join(p, "blobs", "sha256-1d0ad71299d48c2fb7ae2b98e683643e771f8a5b72be34942af90d97a91c1e37"),
filepath.Join(p, "blobs", "sha256-6d6e36c1f90fc7deefc33a7300aa21ad4b67c506e33ecdeddfafa98147e60bbf"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
filepath.Join(p, "blobs", "sha256-9443591d14be23c1e33d101934d76ad03bdb0715fe0879e8b0d1819e7bb063dd"),
})
actual, err = os.ReadFile(filepath.Join(p, "blobs", "sha256-12f58bb75cb3042d69a7e013ab87fb3c3c7088f50ddc62f0c77bd332f0d44d35"))
if err != nil {
t.Fatal(err)
}
expect, err = json.Marshal(map[string]any{"temperature": 0.6, "top_k": 10, "top_p": 0.7, "stop": []string{"<|endoftext|>"}})
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(bytes.TrimSpace(expect), bytes.TrimSpace(actual)) {
t.Errorf("expected %s, actual %s", string(expect), string(actual))
}
}
func TestCreateReplacesMessages(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Messages: []api.Message{
{
Role: "assistant",
Content: "What is my purpose?",
},
{
Role: "user",
Content: "You run tests.",
},
{
Role: "assistant",
Content: "Oh, my god.",
},
},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-298baeaf6928a60cf666d88d64a1ba606feb43a2865687c39e40652e407bffc4"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
filepath.Join(p, "blobs", "sha256-c84aee28f2af350596f674de51d2a802ea782653ef2930a21d48bd43d5cd5317"),
})
w = createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test2",
From: "test",
Messages: []api.Message{
{
Role: "assistant",
Content: "You're a test, Harry.",
},
{
Role: "user",
Content: "I-I'm a what?",
},
{
Role: "assistant",
Content: "A test. And a thumping good one at that, I'd wager.",
},
},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test2", "latest"),
})
// Old layers will not have been pruned
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-09cfac3e6a637e25cb41aa85c24c110dc17ba89634de7df141b564dd2da4168b"),
filepath.Join(p, "blobs", "sha256-298baeaf6928a60cf666d88d64a1ba606feb43a2865687c39e40652e407bffc4"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
filepath.Join(p, "blobs", "sha256-a60ecc9da299ec7ede453f99236e5577fd125e143689b646d9f0ddc9971bf4db"),
filepath.Join(p, "blobs", "sha256-c84aee28f2af350596f674de51d2a802ea782653ef2930a21d48bd43d5cd5317"),
})
type message struct {
Role string `json:"role"`
Content string `json:"content"`
}
f, err := os.Open(filepath.Join(p, "blobs", "sha256-a60ecc9da299ec7ede453f99236e5577fd125e143689b646d9f0ddc9971bf4db"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
var actual []message
if err := json.NewDecoder(f).Decode(&actual); err != nil {
t.Fatal(err)
}
expect := []message{
{Role: "assistant", Content: "You're a test, Harry."},
{Role: "user", Content: "I-I'm a what?"},
{Role: "assistant", Content: "A test. And a thumping good one at that, I'd wager."},
}
if !slices.Equal(actual, expect) {
t.Errorf("expected %s, actual %s", expect, actual)
}
}
func TestCreateTemplateSystem(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Template: "{{ .System }} {{ .Prompt }}",
System: "Say bye!",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-0a04d979734167da3b80811a1874d734697f366a689f3912589b99d2e86e7ad1"),
filepath.Join(p, "blobs", "sha256-4c5f51faac758fecaff8db42f0b7382891a4d0c0bb885f7b86be88c814a7cc86"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
filepath.Join(p, "blobs", "sha256-fe7ac77b725cda2ccad03f88a880ecdfd7a33192d6cae08fce2c0ee1455991ed"),
})
template, err := os.ReadFile(filepath.Join(p, "blobs", "sha256-fe7ac77b725cda2ccad03f88a880ecdfd7a33192d6cae08fce2c0ee1455991ed"))
if err != nil {
t.Fatal(err)
}
if string(template) != "{{ .System }} {{ .Prompt }}" {
t.Errorf("expected \"{{ .System }} {{ .Prompt }}\", actual %s", template)
}
system, err := os.ReadFile(filepath.Join(p, "blobs", "sha256-4c5f51faac758fecaff8db42f0b7382891a4d0c0bb885f7b86be88c814a7cc86"))
if err != nil {
t.Fatal(err)
}
if string(system) != "Say bye!" {
t.Errorf("expected \"Say bye!\", actual %s", system)
}
t.Run("incomplete template", func(t *testing.T) {
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Template: "{{ .Prompt",
Stream: &stream,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status code 400, actual %d", w.Code)
}
})
t.Run("template with unclosed if", func(t *testing.T) {
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Template: "{{ if .Prompt }}",
Stream: &stream,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status code 400, actual %d", w.Code)
}
})
t.Run("template with undefined function", func(t *testing.T) {
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Template: "{{ Prompt }}",
Stream: &stream,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status code 400, actual %d", w.Code)
}
})
}
func TestCreateAndShowRemoteModel(t *testing.T) {
gin.SetMode(gin.TestMode)
var s Server
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "test",
From: "bob",
RemoteHost: "https://ollama.com",
Info: map[string]any{
"capabilities": []string{"completion", "tools", "thinking"},
"model_family": "gptoss",
"context_length": 131072,
"embedding_length": 2880,
"quantization_level": "MXFP4",
"parameter_size": "20.9B",
},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("exected status code 200, actual %d", w.Code)
}
w = createRequest(t, s.ShowHandler, api.ShowRequest{Model: "test"})
if w.Code != http.StatusOK {
t.Fatalf("exected status code 200, actual %d", w.Code)
}
var resp api.ShowResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
expectedDetails := api.ModelDetails{
ParentModel: "",
Format: "",
Family: "gptoss",
Families: []string{"gptoss"},
ParameterSize: "20.9B",
QuantizationLevel: "MXFP4",
}
if !reflect.DeepEqual(resp.Details, expectedDetails) {
t.Errorf("model details: expected %#v, actual %#v", expectedDetails, resp.Details)
}
expectedCaps := []model.Capability{
model.Capability("completion"),
model.Capability("tools"),
model.Capability("thinking"),
}
if !slices.Equal(resp.Capabilities, expectedCaps) {
t.Errorf("capabilities: expected %#v, actual %#v", expectedCaps, resp.Capabilities)
}
v, ok := resp.ModelInfo["gptoss.context_length"]
ctxlen := v.(float64)
if !ok || int(ctxlen) != 131072 {
t.Errorf("context len: expected %d, actual %d", 131072, int(ctxlen))
}
v, ok = resp.ModelInfo["gptoss.embedding_length"]
embedlen := v.(float64)
if !ok || int(embedlen) != 2880 {
t.Errorf("embed len: expected %d, actual %d", 2880, int(embedlen))
}
fmt.Printf("resp = %#v\n", resp)
}
func TestCreateRemoteModelRejectsDraftFiles(t *testing.T) {
gin.SetMode(gin.TestMode)
var s Server
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "test-remote-draft",
From: "bob",
RemoteHost: "https://ollama.com",
DraftFiles: map[string]string{"draft.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusBadRequest {
t.Fatalf("expected status code 400, got %d", w.Code)
}
var resp map[string]string
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp["error"] != errRemoteDraftUnsupported.Error() {
t.Fatalf("expected error %q, got %q", errRemoteDraftUnsupported, resp["error"])
}
}
func TestCreateFromCloudSourceSuffix(t *testing.T) {
gin.SetMode(gin.TestMode)
var s Server
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "test-cloud-from-suffix",
From: "gpt-oss:20b:cloud",
Info: map[string]any{
"capabilities": []string{"completion"},
},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, got %d", w.Code)
}
w = createRequest(t, s.ShowHandler, api.ShowRequest{Model: "test-cloud-from-suffix"})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, got %d", w.Code)
}
var resp api.ShowResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatal(err)
}
if resp.RemoteHost != "https://ollama.com:443" {
t.Fatalf("expected remote host https://ollama.com:443, got %q", resp.RemoteHost)
}
if resp.RemoteModel != "gpt-oss:20b" {
t.Fatalf("expected remote model gpt-oss:20b, got %q", resp.RemoteModel)
}
}
func TestCreateLicenses(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
License: []string{"MIT", "Apache-2.0"},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "manifests", "*", "*", "*", "*"), []string{
filepath.Join(p, "manifests", "registry.ollama.ai", "library", "test", "latest"),
})
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-2af71558e438db0b73a20beab92dc278a94e1bbe974c00c1a33e3ab62d53a608"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
filepath.Join(p, "blobs", "sha256-a762f214df0d96c9a7b82f96da98d99ceb2776c88e3ea7ffa09d1e5835516ec6"),
filepath.Join(p, "blobs", "sha256-e5dcffe836b6ec8a58e492419b550e65fb8cbdc308503979e5dacb33ac7ea3b7"),
})
mit, err := os.ReadFile(filepath.Join(p, "blobs", "sha256-e5dcffe836b6ec8a58e492419b550e65fb8cbdc308503979e5dacb33ac7ea3b7"))
if err != nil {
t.Fatal(err)
}
if string(mit) != "MIT" {
t.Errorf("expected MIT, actual %s", mit)
}
apache, err := os.ReadFile(filepath.Join(p, "blobs", "sha256-2af71558e438db0b73a20beab92dc278a94e1bbe974c00c1a33e3ab62d53a608"))
if err != nil {
t.Fatal(err)
}
if string(apache) != "Apache-2.0" {
t.Errorf("expected Apache-2.0, actual %s", apache)
}
}
func TestCreateDetectTemplate(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
t.Run("matched", func(t *testing.T) {
_, digest := createBinFile(t, ggml.KV{
"tokenizer.chat_template": "{{ bos_token }}{% for message in messages %}{{'<|' + message['role'] + '|>' + '\n' + message['content'] + '<|end|>\n' }}{% endfor %}{% if add_generation_prompt %}{{ '<|assistant|>\n' }}{% else %}{{ eos_token }}{% endif %}",
}, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-0d79f567714c62c048378f2107fb332dabee0135d080c302d884317da9433cc5"),
filepath.Join(p, "blobs", "sha256-3322a0c650c758b7386ff55629d27d07c07b6c3d3515e259dc3e5598c41e9f4e"),
filepath.Join(p, "blobs", "sha256-35360843d0c84fb1506952a131bbef13cd2bb4a541251f22535170c05b56e672"),
filepath.Join(p, "blobs", "sha256-a56c12acca8068cb6c335e237da6643e8a802a92959a63ad5bd17828e3b5e9b0"),
})
})
t.Run("unmatched", func(t *testing.T) {
_, digest := createBinFile(t, nil, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
checkFileExists(t, filepath.Join(p, "blobs", "*"), []string{
filepath.Join(p, "blobs", "sha256-6bcdb8859d417753645538d7bbfbd7ca91a3f0c191aef5379c53c05e86b669dd"),
filepath.Join(p, "blobs", "sha256-89a2116c3a82d6a97f59f748d86ed4417214353fd178ee54df418fde32495fad"),
})
})
}
func TestCreateGemma4KeepsDynamicRendererAlias(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, ggml.KV{
"general.architecture": "gemma4",
"general.parameter_count": uint64(25_200_000_000),
}, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
mf, err := manifest.ParseNamedManifest(model.ParseName("test"))
if err != nil {
t.Fatalf("parse manifest: %v", err)
}
if mf.Config.Digest == "" {
t.Fatalf("unexpected empty config digest for manifest")
}
configPath, err := manifest.BlobsPath(mf.Config.Digest)
if err != nil {
t.Fatalf("config blob path: %v", err)
}
cfgFile, err := os.Open(configPath)
if err != nil {
t.Fatalf("open config blob: %v", err)
}
defer cfgFile.Close()
var cfg model.ConfigV2
if err := json.NewDecoder(cfgFile).Decode(&cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg.Renderer != gemma4RendererLegacy {
t.Fatalf("expected renderer %q, got %q", gemma4RendererLegacy, cfg.Renderer)
}
if cfg.Parser != "gemma4" {
t.Fatalf("expected parser %q, got %q", "gemma4", cfg.Parser)
}
}
func TestCreateLagunaDetectsRendererParser(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, ggml.KV{
"general.architecture": "laguna",
"general.parameter_count": uint64(33_400_000_000),
}, nil)
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: "test",
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
mf, err := manifest.ParseNamedManifest(model.ParseName("test"))
if err != nil {
t.Fatalf("parse manifest: %v", err)
}
if mf.Config.Digest == "" {
t.Fatalf("unexpected empty config digest for manifest")
}
configPath, err := manifest.BlobsPath(mf.Config.Digest)
if err != nil {
t.Fatalf("config blob path: %v", err)
}
cfgFile, err := os.Open(configPath)
if err != nil {
t.Fatalf("open config blob: %v", err)
}
defer cfgFile.Close()
var cfg model.ConfigV2
if err := json.NewDecoder(cfgFile).Decode(&cfg); err != nil {
t.Fatalf("decode config: %v", err)
}
if cfg.Renderer != "laguna" {
t.Fatalf("expected renderer %q, got %q", "laguna", cfg.Renderer)
}
if cfg.Parser != "laguna" {
t.Fatalf("expected parser %q, got %q", "laguna", cfg.Parser)
}
}
func TestCreateNemotronHDefaultsRendererParser(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, arch := range []string{"nemotron_h", "nemotron_h_moe", "nemotron_h_omni"} {
t.Run(arch, func(t *testing.T) {
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, ggml.KV{
"general.architecture": arch,
}, nil)
name := strings.ReplaceAll(arch, "_", "-")
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: name,
Files: map[string]string{"test.gguf": digest},
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
cfg := readCreatedModelConfig(t, name)
if cfg.Renderer != "nemotron-3-nano" {
t.Fatalf("expected renderer %q, got %q", "nemotron-3-nano", cfg.Renderer)
}
if cfg.Parser != "nemotron-3-nano" {
t.Fatalf("expected parser %q, got %q", "nemotron-3-nano", cfg.Parser)
}
})
}
}
func TestCreateNemotronHDefaultsKeepExplicitRendererParser(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, arch := range []string{"nemotron_h", "nemotron_h_moe", "nemotron_h_omni"} {
t.Run(arch, func(t *testing.T) {
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
_, digest := createBinFile(t, ggml.KV{
"general.architecture": arch,
}, nil)
const (
renderer = "custom-renderer"
parser = "custom-parser"
)
name := strings.ReplaceAll(arch, "_", "-") + "-custom"
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Name: name,
Files: map[string]string{"test.gguf": digest},
Renderer: renderer,
Parser: parser,
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status code 200, actual %d", w.Code)
}
cfg := readCreatedModelConfig(t, name)
if cfg.Renderer != renderer {
t.Fatalf("expected renderer %q, got %q", renderer, cfg.Renderer)
}
if cfg.Parser != parser {
t.Fatalf("expected parser %q, got %q", parser, cfg.Parser)
}
})
}
}
func TestDetectModelTypeFromFiles(t *testing.T) {
t.Run("gguf file", func(t *testing.T) {
_, digest := createBinFile(t, nil, nil)
files := map[string]string{
"model.gguf": digest,
}
modelType := detectModelTypeFromFiles(files)
if modelType != "gguf" {
t.Fatalf("expected model type 'gguf', got %q", modelType)
}
})
t.Run("gguf file w/o extension", func(t *testing.T) {
_, digest := createBinFile(t, nil, nil)
files := map[string]string{
fmt.Sprintf("%x", digest): digest,
}
modelType := detectModelTypeFromFiles(files)
if modelType != "gguf" {
t.Fatalf("expected model type 'gguf', got %q", modelType)
}
})
t.Run("safetensors file", func(t *testing.T) {
files := map[string]string{
"model.safetensors": "sha256:abc123",
}
modelType := detectModelTypeFromFiles(files)
if modelType != "safetensors" {
t.Fatalf("expected model type 'safetensors', got %q", modelType)
}
})
t.Run("unsupported file type", func(t *testing.T) {
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
data := []byte("12345678")
digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
if err := os.MkdirAll(filepath.Join(p, "blobs"), 0o755); err != nil {
t.Fatal(err)
}
f, err := os.Create(filepath.Join(p, "blobs", fmt.Sprintf("sha256-%s", strings.TrimPrefix(digest, "sha256:"))))
if err != nil {
t.Fatal(err)
}
defer f.Close()
if _, err := f.Write(data); err != nil {
t.Fatal(err)
}
files := map[string]string{
"model.bin": digest,
}
modelType := detectModelTypeFromFiles(files)
if modelType != "" {
t.Fatalf("expected empty model type for unsupported file, got %q", modelType)
}
})
t.Run("file with less than 4 bytes", func(t *testing.T) {
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
data := []byte("123")
digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
if err := os.MkdirAll(filepath.Join(p, "blobs"), 0o755); err != nil {
t.Fatal(err)
}
f, err := os.Create(filepath.Join(p, "blobs", fmt.Sprintf("sha256-%s", strings.TrimPrefix(digest, "sha256:"))))
if err != nil {
t.Fatal(err)
}
defer f.Close()
if _, err := f.Write(data); err != nil {
t.Fatal(err)
}
files := map[string]string{
"noext": digest,
}
modelType := detectModelTypeFromFiles(files)
if modelType != "" {
t.Fatalf("expected empty model type for small file, got %q", modelType)
}
})
}
// createTestBlob creates a blob in the blobs directory and returns its digest.
func createTestBlob(t *testing.T, data []byte) string {
t.Helper()
digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
blobPath, err := manifest.BlobsPath(digest)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(blobPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(blobPath, data, 0o644); err != nil {
t.Fatal(err)
}
return digest
}
// createSafetensorsTestModel creates a minimal safetensors model manifest for testing.
func createSafetensorsTestModel(t *testing.T, modelName string, config model.ConfigV2, extraLayers []manifest.Layer) {
t.Helper()
// Create a fake tensor blob
tensorData := []byte("fake-tensor-data-for-testing")
tensorDigest := createTestBlob(t, tensorData)
layers := []manifest.Layer{
{
MediaType: manifest.MediaTypeImageTensor,
Digest: tensorDigest,
Size: int64(len(tensorData)),
Name: "model.embed_tokens.weight",
},
}
layers = append(layers, extraLayers...)
configLayer, err := createConfigLayer(layers, config)
if err != nil {
t.Fatalf("failed to create config layer: %v", err)
}
name := model.ParseName(modelName)
if err := manifest.WriteManifest(name, *configLayer, layers); err != nil {
t.Fatalf("failed to write manifest: %v", err)
}
}
func TestCreateFromSafetensorsModel_PreservesConfig(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
// Create a source safetensors model with specific config fields
createSafetensorsTestModel(t, "source-model", model.ConfigV2{
ModelFormat: "safetensors",
Capabilities: []string{"completion"},
Requires: "0.14.0",
Renderer: "gemma3",
Parser: "gemma3",
}, nil)
// Create a derived model FROM the source
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "derived-model",
From: "source-model",
System: "You are a pirate.",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Read the derived model's config
derivedName := model.ParseName("derived-model")
mf, err := manifest.ParseNamedManifest(derivedName)
if err != nil {
t.Fatalf("failed to parse derived manifest: %v", err)
}
configBlobPath, err := manifest.BlobsPath(mf.Config.Digest)
if err != nil {
t.Fatalf("failed to get config blob path: %v", err)
}
configBlob, err := os.ReadFile(configBlobPath)
if err != nil {
t.Fatalf("failed to read config blob: %v", err)
}
var cfg model.ConfigV2
if err := json.Unmarshal(configBlob, &cfg); err != nil {
t.Fatalf("failed to unmarshal config: %v", err)
}
// Verify safetensors-specific config fields are preserved
if cfg.ModelFormat != "safetensors" {
t.Errorf("ModelFormat = %q, want %q", cfg.ModelFormat, "safetensors")
}
if !slices.Contains(cfg.Capabilities, "completion") {
t.Errorf("Capabilities = %v, want to contain %q", cfg.Capabilities, "completion")
}
if cfg.Requires != "0.14.0" {
t.Errorf("Requires = %q, want %q", cfg.Requires, "0.14.0")
}
if cfg.Renderer != "gemma3" {
t.Errorf("Renderer = %q, want %q", cfg.Renderer, "gemma3")
}
if cfg.Parser != "gemma3" {
t.Errorf("Parser = %q, want %q", cfg.Parser, "gemma3")
}
// Verify system prompt was added
var hasSystem bool
for _, l := range mf.Layers {
if l.MediaType == "application/vnd.ollama.image.system" {
hasSystem = true
break
}
}
if !hasSystem {
t.Error("expected system prompt layer in derived model")
}
// Verify tensor layers were copied with names preserved
var tensorNames []string
for _, l := range mf.Layers {
if l.MediaType == manifest.MediaTypeImageTensor {
tensorNames = append(tensorNames, l.Name)
}
}
if len(tensorNames) == 0 {
t.Error("expected tensor layers in derived model")
}
for _, name := range tensorNames {
if name == "" {
t.Error("tensor layer has empty name — names must be preserved from source")
}
}
}
func TestCreateFromSafetensorsModel_OverrideSystem(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
// Create source with a system prompt
createSafetensorsTestModel(t, "source-with-system", model.ConfigV2{
ModelFormat: "safetensors",
Capabilities: []string{"completion"},
}, nil)
// First create with system prompt
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "source-with-system",
From: "source-with-system",
System: "Original system prompt",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Now create a derived model with a different system prompt
w = createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "derived-new-system",
From: "source-with-system",
System: "New system prompt",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
// Verify ModelFormat is preserved even after override
derivedName := model.ParseName("derived-new-system")
mf, err := manifest.ParseNamedManifest(derivedName)
if err != nil {
t.Fatalf("failed to parse derived manifest: %v", err)
}
configBlobPath, _ := manifest.BlobsPath(mf.Config.Digest)
configBlob, _ := os.ReadFile(configBlobPath)
var cfg model.ConfigV2
json.Unmarshal(configBlob, &cfg)
if cfg.ModelFormat != "safetensors" {
t.Errorf("ModelFormat = %q, want %q", cfg.ModelFormat, "safetensors")
}
}
func TestCreateFromSafetensorsModel_PreservesLayerNames(t *testing.T) {
gin.SetMode(gin.TestMode)
p := t.TempDir()
t.Setenv("OLLAMA_MODELS", p)
var s Server
// Create JSON config blobs to include as layers
configJSON := []byte(`{"architectures": ["LlamaForCausalLM"], "model_type": "llama"}`)
configDigest := createTestBlob(t, configJSON)
tokenizerJSON := []byte(`{"version": "1.0"}`)
tokenizerDigest := createTestBlob(t, tokenizerJSON)
extraLayers := []manifest.Layer{
{
MediaType: "application/vnd.ollama.image.json",
Digest: configDigest,
Size: int64(len(configJSON)),
Name: "config.json",
},
{
MediaType: "application/vnd.ollama.image.json",
Digest: tokenizerDigest,
Size: int64(len(tokenizerJSON)),
Name: "tokenizer.json",
},
}
createSafetensorsTestModel(t, "source-named-layers", model.ConfigV2{
ModelFormat: "safetensors",
Capabilities: []string{"completion"},
}, extraLayers)
// Create derived model
w := createRequest(t, s.CreateHandler, api.CreateRequest{
Model: "derived-named-layers",
From: "source-named-layers",
Stream: &stream,
})
if w.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
derivedName := model.ParseName("derived-named-layers")
mf, err := manifest.ParseNamedManifest(derivedName)
if err != nil {
t.Fatalf("failed to parse derived manifest: %v", err)
}
// Check tensor layer names are preserved
for _, l := range mf.Layers {
if l.MediaType == manifest.MediaTypeImageTensor && l.Name == "" {
t.Error("tensor layer has empty name — names must be preserved from source")
}
}
// Check JSON layer names are preserved
jsonNames := make(map[string]bool)
for _, l := range mf.Layers {
if l.MediaType == "application/vnd.ollama.image.json" && l.Name != "" {
jsonNames[l.Name] = true
}
}
if !jsonNames["config.json"] {
t.Error("config.json layer name not preserved in derived model")
}
if !jsonNames["tokenizer.json"] {
t.Error("tokenizer.json layer name not preserved in derived model")
}
}