* 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>
2018 lines
57 KiB
Go
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")
|
|
}
|
|
}
|