Compare commits

...
Author SHA1 Message Date
Daniel Hiltgen a51df81573 test: revamp integration test entrpoints (#16560)
This refactors the existing integration tests into 3 priumary groups: fast,
release, and library.  It also refines some of the release tests to drop some
of the older models and pick up newer models, while retaining the broad
coverage in the library group.
2026-07-21 16:06:38 -07:00
Daniel Hiltgen a18c230189 model: add Laguna v8 chat support and fix Metal inference (#17291)
Add a laguna-v8 renderer/parser matching the Laguna XS 2.1 template, and fix v2 handling of embedded thinking and structured tool arguments.

Prevent FP16 overflow in Metal's quantized routed-MoE prefill path by scaling the linear branch and folding the inverse into the routing scale. Other backends and token-generation paths are unchanged.

Add comprehensive v2/v8 Jinja parity and parser tests.
2026-07-21 16:06:29 -07:00
Daniel Hiltgen e21d5327b0 CI: fix missing CUDA v13.4 sub-package (#17288)
Needed for cross-compiling WoA
2026-07-21 12:25:10 -07:00
Jhye 4d1b53e6fb server: detect download stalls before the first byte (#17259)
* server: detect download stalls before the first byte

* server: keep stall timeout out of download API
2026-07-21 11:28:19 -07:00
Daniel Hiltgen 6100aca085 win: support CUDA on Windows ARM64 (#16931) 2026-07-21 10:53:30 -07:00
Daniel Hiltgen 72116bafb3 llama: enable dio on linux CUDA/ROCm iGPUs (#17286)
Avoid double memory consumption by enabling direct IO for iGPUs
2026-07-21 10:53:08 -07:00
Patrick Devine e2c2edcc27 docs: add renderer/parser fields to the API docs (#17275) 2026-07-20 16:22:46 -07:00
Daniel Hiltgen de1ce45913 cuda: add CC 10.0 for linux in CUDA v12 (#17025)
Add compute capability 10.0 to the Linux CUDA v12 preset so B200-class devices can use the cuda_v12 backend with drivers that do not meet the CUDA v13 minimum.

Fixes #12583
2026-07-20 13:09:36 -07:00
Daniel Hiltgen 51fc00122b build: bump Linux toolchain to GCC 13 (#17244)
GCC 11 builds broken AMX code which causes the Sapphire Rapids CPU backend to crash.

Fixes #17006
Fixes #17205
2026-07-20 11:54:39 -07:00
Daniel Hiltgen 445284b428 MLX update (#17189) 2026-07-20 11:54:24 -07:00
Parth Sareen e8f7c93a0b launch: update Hermes integration (#17202) 2026-07-20 11:28:01 -07:00
Parth Sareen 0de38190d7 cmd/tui/chat: render bold emphasis consistently across markdown (#17224) 2026-07-20 11:25:43 -07:00
Parth Sareen 681dfaedcc cmd: remove standalone agent command (#17229) 2026-07-20 11:25:31 -07:00
Parth Sareen 9893d39218 cmd: complete slash commands before submitting (#17230) 2026-07-20 11:25:12 -07:00
Parth Sareen 5ba17e6fdf agent/tui: remove redundant context-window refreshes from event loop (#17241) 2026-07-20 11:25:01 -07:00
Parth Sareen 6f3b997dec cmd: route root command server start through checkServerHeartbeat (#17245)
The bare `ollama` command (and `ollama launch` with no integration) used a
bespoke `ensureServerRunning` that forked `ollama serve` directly and polled
its heartbeat forever (no timeout, no platform-aware launch). Every other
subcommand (`ollama run`, `ollama pull`, `ollama launch <integration>`, ...)
goes through `checkServerHeartbeat` -> `startApp`, so the root command behaved
differently and could hang indefinitely.

Route `runInteractiveTUI` through `checkServerHeartbeat(cmd, nil)` — the same
path `ollama launch <thing>` uses — so the root command is consistent and no
longer runs an unbounded server-spawn loop. `ensureServerRunning` and its
`backgroundServerSysProcAttr` helpers (only it referenced them) are removed,
along with the now-unused `os/exec` import.

The platform `startApp`/`waitForServer` paths are unchanged, so behavior on
macOS/Windows is identical to the other subcommands, and on Linux the root
command now errors the same way the subcommands already do when no server is
running.
2026-07-20 11:24:25 -07:00
Daniel Hiltgen cc62676656 llama.cpp update (#17186) 2026-07-20 11:21:09 -07:00
71 changed files with 3831 additions and 1778 deletions

No files matched your search

+16
View File
@@ -124,6 +124,22 @@ jobs:
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.0'
- os: windows
arch: amd64
preset: 'CUDA 13 ARM64'
build-steps: cuda13Arm64Cross
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
cuda-components:
- '"cudart"'
- '"cudart_cross"'
- '"nvcc"'
- '"nvcc_cross"'
- '"cublas_cross"'
- '"cublas_dev"'
- '"crt"'
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.4'
- os: windows
arch: amd64
preset: 'ROCm 7'
@@ -321,6 +321,22 @@ jobs:
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.0'
- os: windows
arch: amd64
preset: 'CUDA 13 ARM64'
build-steps: cuda13Arm64Cross
install: https://packages.nvidia.com/prerelease/cuda/13.4.0/local_installers/cuda_13.4.0_windows_x86_64.exe
cuda-components:
- '"cudart"'
- '"cudart_cross"'
- '"nvcc"'
- '"nvcc_cross"'
- '"cublas_cross"'
- '"cublas_dev"'
- '"crt"'
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.4'
- os: windows
arch: amd64
preset: 'ROCm 7'
+7 -7
View File
@@ -15,9 +15,9 @@ FROM scratch AS local-mlx
FROM scratch AS local-mlx-c
FROM --platform=linux/amd64 rocm/dev-almalinux-8:${ROCMVERSION}-complete AS base-amd64
RUN dnf install -y yum-utils ccache gcc-toolset-11-gcc gcc-toolset-11-gcc-c++ gcc-toolset-11-binutils \
RUN dnf install -y yum-utils ccache gcc-toolset-13-gcc gcc-toolset-13-gcc-c++ gcc-toolset-13-binutils \
&& yum-config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
FROM --platform=linux/arm64 almalinux:8 AS base-arm64
# install epel-release for ccache
@@ -42,8 +42,8 @@ ENV LDFLAGS=-s
#
FROM base AS cpu-deps
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
RUN dnf install -y gcc-toolset-13-gcc gcc-toolset-13-gcc-c++
ENV PATH=/opt/rh/gcc-toolset-13/root/usr/bin:$PATH
FROM base AS cuda-12-deps
ARG CUDA12VERSION=12.8
@@ -91,8 +91,8 @@ RUN --mount=type=cache,target=/root/.ccache \
&& for lib in \
/usr/lib64/libgomp.so* \
/usr/lib64/libomp.so* \
/opt/rh/gcc-toolset-11/root/usr/lib64/libgomp.so* \
/opt/rh/gcc-toolset-11/root/usr/lib64/libomp.so*; do \
/opt/rh/gcc-toolset-13/root/usr/lib64/libgomp.so* \
/opt/rh/gcc-toolset-13/root/usr/lib64/libomp.so*; do \
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
done
@@ -124,7 +124,7 @@ FROM scratch AS publish-llama-server-cuda_v13
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
FROM rocm-7-deps AS llama-server-rocm_v7_2
ENV CC=clang CXX=clang++
ENV CC=clang CXX=clang++ CXXFLAGS=--gcc-toolchain=/opt/rh/gcc-toolset-13/root/usr
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
+1 -1
View File
@@ -1 +1 @@
b9888
b10069
+1 -1
View File
@@ -1 +1 @@
de7b4ed986b6d6f55b8ace5e73c24d1ca0bea89b
b7c3dd6d27f45b5365b08a840310187dc503f1db
+3
View File
@@ -706,7 +706,10 @@ type CreateRequest struct {
// Messages is a list of messages added to the model before chat and generation requests.
Messages []Message `json:"messages,omitempty"`
// Renderer is the name of the renderer used when constructing a request to the model.
Renderer string `json:"renderer,omitempty"`
// Parser is the name of the parser used to parse the output of the request.
Parser string `json:"parser,omitempty"`
// Requires is the minimum version of Ollama required by the model.
-137
View File
@@ -28,7 +28,6 @@ import (
type agentTUIOptions struct {
Model string
OpenModelPicker bool
System string
Format string
Options map[string]any
@@ -40,142 +39,6 @@ type agentTUIOptions struct {
MultiModal bool
}
func registerAgentFlags(cmd *cobra.Command) {
cmd.Flags().String("model", "", "Model to use")
cmd.Flags().String("keepalive", "", "Duration to keep a model loaded (e.g. 5m)")
cmd.Flags().String("format", "", "Response format (e.g. json)")
cmd.Flags().String("think", "", "Enable thinking mode: true/false or high/medium/low for supported models")
cmd.Flags().Lookup("think").NoOptDefVal = "true"
cmd.Flags().Bool("auto-approve-tools", false, "Allow agent tools to run without prompting")
cmd.Flags().Bool("yolo", false, "Alias for --auto-approve-tools")
cmd.Flags().Bool("no-tools", false, "Disable agent tools")
}
func AgentHandler(cmd *cobra.Command, _ []string) error {
opts := agentTUIOptions{
Model: strings.TrimSpace(config.LastModel()),
Options: map[string]any{},
}
thinkExplicit, err := applyAgentFlags(cmd, &opts)
if err != nil {
return err
}
if strings.TrimSpace(opts.Model) == "" {
opts.OpenModelPicker = true
} else if cmd.Flags().Lookup("model") == nil || !cmd.Flags().Lookup("model").Changed {
opts.OpenModelPicker = true
}
client, err := api.ClientFromEnvironment()
if err != nil {
return err
}
if opts.OpenModelPicker {
modelName, err := selectAgentModel(cmd.Context(), client, opts.Model)
if errors.Is(err, launch.ErrCancelled) {
return nil
}
if err != nil {
return err
}
opts.Model = modelName
opts.OpenModelPicker = false
}
if strings.TrimSpace(opts.Model) != "" {
info, err := prepareAgentModel(cmd, client, &opts, thinkExplicit)
if err != nil {
if handleCloudAuthorizationError(err) {
return nil
}
return err
}
opts.System = info.System
if err := saveLastAgentModel(opts.Model); err != nil {
return err
}
}
if err := GenerateAgentTUI(cmd, client, opts); err != nil {
if handleCloudAuthorizationError(err) {
return nil
}
return fmt.Errorf("error running agent: %w", err)
}
return nil
}
func applyAgentFlags(cmd *cobra.Command, opts *agentTUIOptions) (bool, error) {
if flag := cmd.Flags().Lookup("model"); flag != nil && flag.Changed {
modelName, err := cmd.Flags().GetString("model")
if err != nil {
return false, err
}
modelName = strings.TrimSpace(modelName)
if modelName == "" {
return false, errors.New("--model cannot be empty")
}
opts.Model = modelName
opts.OpenModelPicker = false
}
format, err := cmd.Flags().GetString("format")
if err != nil {
return false, err
}
opts.Format = format
thinkExplicit := false
thinkFlag := cmd.Flags().Lookup("think")
if thinkFlag != nil && thinkFlag.Changed {
thinkExplicit = true
thinkStr, err := cmd.Flags().GetString("think")
if err != nil {
return false, err
}
switch thinkStr {
case "", "true":
opts.Think = &api.ThinkValue{Value: true}
case "false":
opts.Think = &api.ThinkValue{Value: false}
case "high", "medium", "low", "max":
opts.Think = &api.ThinkValue{Value: thinkStr}
default:
return false, fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, low, or max)", thinkStr)
}
}
keepAlive, err := cmd.Flags().GetString("keepalive")
if err != nil {
return false, err
}
if keepAlive != "" {
d, err := time.ParseDuration(keepAlive)
if err != nil {
return false, err
}
opts.KeepAlive = &api.Duration{Duration: d}
}
autoApprove, err := cmd.Flags().GetBool("auto-approve-tools")
if err != nil {
return false, err
}
yolo, err := cmd.Flags().GetBool("yolo")
if err != nil {
return false, err
}
opts.AllowAllTools = autoApprove || yolo
toolsDisabled, err := cmd.Flags().GetBool("no-tools")
if err != nil {
return false, err
}
opts.ToolsDisabled = toolsDisabled
return thinkExplicit, nil
}
func saveLastAgentModel(model string) error {
model = strings.TrimSpace(model)
if model == "" {
-18
View File
@@ -9,8 +9,6 @@ import (
"testing"
"time"
"github.com/spf13/cobra"
coreagent "github.com/ollama/ollama/agent"
agenttools "github.com/ollama/ollama/agent/tools"
"github.com/ollama/ollama/api"
@@ -168,19 +166,3 @@ func TestSaveLastAgentModel(t *testing.T) {
t.Fatalf("blank save changed last model to %q", got)
}
}
func TestApplyAgentFlagsNoTools(t *testing.T) {
cmd := &cobra.Command{}
registerAgentFlags(cmd)
if err := cmd.Flags().Set("no-tools", "true"); err != nil {
t.Fatal(err)
}
var opts agentTUIOptions
if _, err := applyAgentFlags(cmd, &opts); err != nil {
t.Fatalf("applyAgentFlags returned error: %v", err)
}
if !opts.ToolsDisabled {
t.Fatal("--no-tools should disable tools")
}
}
-13
View File
@@ -1,13 +0,0 @@
//go:build !windows
package cmd
import "syscall"
// backgroundServerSysProcAttr returns SysProcAttr for running the server in the background on Unix.
// Setpgid prevents the server from being killed when the parent process exits.
func backgroundServerSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
Setpgid: true,
}
}
-12
View File
@@ -1,12 +0,0 @@
package cmd
import "syscall"
// backgroundServerSysProcAttr returns SysProcAttr for running the server in the background on Windows.
// CREATE_NO_WINDOW (0x08000000) prevents a console window from appearing.
func backgroundServerSysProcAttr() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
CreationFlags: 0x08000000,
HideWindow: true,
}
}
+3 -49
View File
@@ -16,7 +16,6 @@ import (
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"path"
"path/filepath"
@@ -2099,40 +2098,6 @@ Environment Variables:
cmd.SetUsageTemplate(cmd.UsageTemplate() + envUsage)
}
// ensureServerRunning checks if the ollama server is running and starts it in the background if not.
func ensureServerRunning(ctx context.Context) error {
client, err := api.ClientFromEnvironment()
if err != nil {
return err
}
// Check if server is already running
if err := client.Heartbeat(ctx); err == nil {
return nil // server is already running
}
// Server not running, start it in the background
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("could not find executable: %w", err)
}
serverCmd := exec.CommandContext(ctx, exe, "serve")
serverCmd.Env = os.Environ()
serverCmd.SysProcAttr = backgroundServerSysProcAttr()
if err := serverCmd.Start(); err != nil {
return fmt.Errorf("failed to start server: %w", err)
}
// Wait for the server to be ready
for {
time.Sleep(500 * time.Millisecond)
if err := client.Heartbeat(ctx); err == nil {
return nil // server has started
}
}
}
func launchInteractiveModel(cmd *cobra.Command, modelName string) error {
client, err := api.ClientFromEnvironment()
if err != nil {
@@ -2166,9 +2131,9 @@ func launchInteractiveModel(cmd *cobra.Command, modelName string) error {
// runInteractiveTUI runs the main interactive TUI menu.
func runInteractiveTUI(cmd *cobra.Command) {
// Ensure the server is running before showing the TUI
if err := ensureServerRunning(cmd.Context()); err != nil {
fmt.Fprintf(os.Stderr, "Error starting server: %v\n", err)
// Ensure the server is running via the shared checkServerHeartbeat path.
if err := checkServerHeartbeat(cmd, nil); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return
}
@@ -2371,15 +2336,6 @@ func NewCLI() *cobra.Command {
runCmd.Flags().Bool("imagegen", false, "Use the imagegen runner for LLM inference")
runCmd.Flags().MarkHidden("imagegen")
agentCmd := &cobra.Command{
Use: "agent",
Short: "Run an agent",
Args: cobra.ExactArgs(0),
PreRunE: checkServerHeartbeat,
RunE: AgentHandler,
}
registerAgentFlags(agentCmd)
stopCmd := &cobra.Command{
Use: "stop MODEL",
Short: "Stop a running model",
@@ -2510,7 +2466,6 @@ func NewCLI() *cobra.Command {
createCmd,
showCmd,
runCmd,
agentCmd,
stopCmd,
pullCmd,
pushCmd,
@@ -2558,7 +2513,6 @@ func NewCLI() *cobra.Command {
createCmd,
showCmd,
runCmd,
agentCmd,
stopCmd,
pullCmd,
pushCmd,
+1 -1
View File
@@ -152,7 +152,7 @@ func (h *HermesDesktop) launchArgs(args []string) []string {
}
func (h *HermesDesktop) shouldSkipDesktopBuild(args []string) bool {
if hermesDesktopHasFlag(args, "--skip-build", "--source", "--build-only", "--help", "-h") {
if hermesDesktopHasFlag(args, "--skip-build", "--force-build", "--source", "--build-only", "--help", "-h") {
return false
}
return h.packagedAppExists()
+47
View File
@@ -349,6 +349,46 @@ func TestHermesConfigureUsesLaunchResolvedHostForModelDiscovery(t *testing.T) {
}
}
func TestHermesConfigurePreservesExplicitCloudModel(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withHermesPlatform(t, "darwin")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"qwen3.5:cloud"},{"name":"gemma4"}]}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
withHermesOllamaURL(t, srv.URL)
if err := (&Hermes{}).Configure("qwen3.5:cloud"); err != nil {
t.Fatalf("Configure returned error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, ".hermes", "config.yaml"))
if err != nil {
t.Fatal(err)
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("failed to parse rewritten yaml: %v", err)
}
modelCfg, _ := cfg["model"].(map[string]any)
if got, _ := modelCfg["default"].(string); got != "qwen3.5:cloud" {
t.Fatalf("expected explicit cloud model to be preserved, got %q", got)
}
providers, _ := cfg["providers"].(map[string]any)
provider, _ := providers[hermesProviderKey].(map[string]any)
if got, _ := provider["default_model"].(string); got != "qwen3.5:cloud" {
t.Fatalf("expected provider default model to be preserved, got %q", got)
}
}
func TestHermesConfigureMigratesLegacyManagedAliases(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -678,6 +718,13 @@ func TestHermesDesktopRun(t *testing.T) {
hasPackage: true,
want: "[desktop --skip-build]",
},
{
name: "force build",
goos: runtime.GOOS,
args: []string{"--force-build"},
hasPackage: true,
want: "[desktop --force-build]",
},
{
name: "source mode",
goos: runtime.GOOS,
+10 -5
View File
@@ -224,9 +224,11 @@ func Run(ctx context.Context, opts Options) (*Result, error) {
m.nextImageID, m.nextAudioID = nextInputAttachmentIDsFromMessages(m.messages)
m.nextPastedTextID = nextInputPastedTextIDFromMessages(m.messages)
m.entries = entriesFromMessages(m.messages)
if !m.openModelOnInit {
m.refreshContextWindowTokens(m.opts.Model)
}
// Context window is resolved post-load (chatModelPreloadDoneMsg) rather than
// here: for local models /api/ps only reports the running num_ctx after the
// model loads, and opts.ContextWindowTokens already holds Show's max as a
// pre-load fallback. Refreshing now would just re-derive that same value
// (and block construction on a network call).
m.contextTokens = m.estimatePromptTokens(m.messages, "")
m.contextEstimate = true
if m.openModelOnInit {
@@ -375,7 +377,8 @@ func (m chatModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg.result.WorkingDir != "" {
m.workingDir = msg.result.WorkingDir
}
m.refreshContextWindowTokens(m.responseModelName(&msg.result.Latest))
// Context window is settled by preload (local num_ctx) or is
// static (cloud); no refresh needed post-run.
m.contextTokens = m.estimatePromptTokens(m.messages, "")
m.contextEstimate = true
if !messagesEndWithCompactionResult(m.messages) {
@@ -589,6 +592,9 @@ func (m chatModel) updateKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.insertInputNewline()
return m, nil
}
if m.applySlashCompletion() {
return m, nil
}
return m.handleSubmit()
case tea.KeyCtrlJ:
m.insertInputNewline()
@@ -1093,7 +1099,6 @@ func (m *chatModel) startSkillRun(name, prompt string) (tea.Model, tea.Cmd) {
}
func (m *chatModel) startRunWithMessages(displayInput, historyInput string, newMessages []api.Message, extraSystemPrompt, skillName string) (tea.Model, tea.Cmd) {
m.refreshContextWindowTokens(m.opts.Model)
m.addPromptHistory(historyInput)
m.entries = append(m.entries, newChatEntry(chatEntry{role: "user", content: displayInput}))
if len(newMessages) > 1 {
-2
View File
@@ -109,7 +109,6 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
contextChanged = true
case coreagent.EventToolStarted:
m.resetStreamingState()
m.refreshContextWindowTokens(m.opts.Model)
startedAt := time.Now()
idx := m.findActiveToolEntry(event.ToolCallID)
if idx < 0 {
@@ -127,7 +126,6 @@ func (m *chatModel) applyAgentEvent(event coreagent.Event) {
m.markEntryDirty(idx)
case coreagent.EventToolFinished:
m.resetStreamingState()
m.refreshContextWindowTokens(m.opts.Model)
if event.WorkingDir != "" {
m.workingDir = event.WorkingDir
}
+19 -10
View File
@@ -51,7 +51,6 @@ const (
)
var chatSlashCommands = []chatSlashCommand{
{name: "/clear", description: "clear this chat"},
{name: "/model", description: "switch models"},
{name: "/new", description: "start a new chat"},
{name: "/think", description: "set thinking mode"},
@@ -67,9 +66,6 @@ var chatSlashCommands = []chatSlashCommand{
func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
m.syncInputPlaceholders()
input := strings.TrimSpace(string(m.input))
if selected, ok := m.selectedSlashCommand(); ok {
input = selected
}
if input == "" {
return *m, nil
}
@@ -91,16 +87,31 @@ func (m *chatModel) handleSubmit() (tea.Model, tea.Cmd) {
return m.submitInput(input)
}
func (m chatModel) selectedSlashCommand() (string, bool) {
func (m *chatModel) applySlashCompletion() bool {
input := strings.TrimSpace(string(m.input))
if !strings.HasPrefix(input, "/") {
return "", false
return false
}
if _, _, known := slashCommandInvocation(input); known {
return false
}
completions := m.slashCompletions()
if len(completions) == 0 || !completionIsSelectable(completions) {
return "", false
return false
}
return completions[clamp(m.complete, 0, len(completions)-1)].value, true
selected := completions[clamp(m.complete, 0, len(completions)-1)]
if selected.value == input {
return false
}
// Reset prompt-history state: Up/Down is shared between history recall and
// slash completion, and a recalled prompt may start with "/" and trigger
// completion. Keep the two in sync when we accept a completion.
m.resetPromptHistoryCursor()
m.input = []rune(selected.value)
m.inputCursor = len(m.input)
m.inputCursorSet = true
m.complete = 0
return true
}
func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
@@ -117,8 +128,6 @@ func (m *chatModel) submitInput(input string) (tea.Model, tea.Cmd) {
case command == "/help":
m.entries = append(m.entries, newSlashEntry(m.helpSummary()))
return *m, nil
case command == "/clear" && args == "":
return m.resetChat("cleared")
case command == "/model":
return m.openModelPicker(args)
case command == "/think" && args == "":
+51 -5
View File
@@ -708,7 +708,7 @@ func TestSkillSlashNameResolvesAndRejectsArgsAndUnknown(t *testing.T) {
}
func TestChatDeletedSlashCommandsAreUnknown(t *testing.T) {
for _, command := range []string{"/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
for _, command := range []string{"/clear", "/copy", "/copy-all", "/launch", "/system", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
t.Run(command, func(t *testing.T) {
m := chatModel{input: []rune(command)}
@@ -732,12 +732,12 @@ func TestChatViewRendersSlashCommandSuggestions(t *testing.T) {
}
view := stripANSI(m.View())
for _, want := range []string{"/clear", "/model", "/new", "/think", "/tools"} {
for _, want := range []string{"/model", "/new", "/think", "/tools", "/skills"} {
if !strings.Contains(view, want) {
t.Fatalf("view missing %s suggestion: %q", want, view)
}
}
for _, removed := range []string{"/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
for _, removed := range []string{"/clear", "/copy", "/copy-all", "/history", "/load", "/raw", "/resume", "/set", "/show", "/verbose"} {
if strings.Contains(view, removed) {
t.Fatalf("bare slash should hide removed command %s: %q", removed, view)
}
@@ -872,19 +872,65 @@ func TestChatSlashCommandSuggestionsIncludeThink(t *testing.T) {
}
}
func TestChatEnterAcceptsSelectedSlashCommand(t *testing.T) {
func TestChatEnterFillsSelectedSlashCommandBeforeSubmitting(t *testing.T) {
m := chatModel{input: []rune("/th")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("filling a slash command should not return a command")
}
if got := string(m.input); got != "/think" {
t.Fatalf("input = %q, want completed command", got)
}
if m.thinkPicker != nil {
t.Fatal("filling a slash command should not open its picker")
}
updated, cmd = m.Update(tea.KeyMsg{Type: tea.KeyEnter})
m = updated.(chatModel)
if cmd != nil {
t.Fatal("think command should not return a command")
}
if m.thinkPicker == nil {
t.Fatal("selected /think command should open picker")
t.Fatal("second enter should submit the completed /think command")
}
}
func TestChatEnterSubmitsExactSlashCommandAliases(t *testing.T) {
t.Run("help", func(t *testing.T) {
m := chatModel{input: []rune("/?")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil {
t.Fatal("help alias should not return a command")
}
m = updated.(chatModel)
if len(m.entries) != 1 || m.entries[0].role != "slash" {
t.Fatalf("entries = %#v, want help output", m.entries)
}
if got := string(m.input); got != "" {
t.Fatalf("input = %q, want cleared after submitting alias", got)
}
})
t.Run("exit", func(t *testing.T) {
m := chatModel{input: []rune("/exit")}
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd == nil {
t.Fatal("exit alias should return the quit command")
}
m = updated.(chatModel)
if !m.quitting {
t.Fatal("exit alias should quit without filling /bye first")
}
if got := string(m.input); got != "" {
t.Fatalf("input = %q, want cleared after submitting alias", got)
}
})
}
func TestChatSlashCommandsRunWhileModelResponds(t *testing.T) {
m := chatModel{running: true, input: []rune("/help")}
+158 -29
View File
@@ -2,8 +2,11 @@ package chat
import (
"strings"
"unicode"
"unicode/utf8"
"github.com/charmbracelet/lipgloss"
"github.com/mattn/go-runewidth"
)
func renderMarkdownForView(markdown string, width int) string {
@@ -34,7 +37,7 @@ func renderMarkdownForView(markdown string, width int) string {
}
if heading, ok := markdownHeading(trimmed); ok {
rendered = append(rendered, chatHeaderStyle.Render(heading))
rendered = append(rendered, chatHeaderStyle.Render(renderMarkdownRunes(parseMarkdownInline(heading))))
continue
}
@@ -42,9 +45,7 @@ func renderMarkdownForView(markdown string, width int) string {
rendered = append(rendered, "")
continue
}
for _, wrapped := range wrapChatText(line, width) {
rendered = append(rendered, renderMarkdownInline(wrapped))
}
rendered = append(rendered, wrapMarkdownInline(line, width)...)
}
return strings.Join(rendered, "\n")
}
@@ -71,22 +72,148 @@ func markdownHeading(line string) (string, bool) {
return strings.TrimSpace(line[level:]), true
}
func renderMarkdownInline(line string) string {
type markdownInlineStyle uint8
const (
markdownPlain markdownInlineStyle = iota
markdownStrong
markdownCode
)
type markdownInlineRune struct {
r rune
style markdownInlineStyle
}
// wrapMarkdownInline parses a complete source line before wrapping it. That
// keeps emphasis intact when its opening and closing delimiters land on
// different visual lines.
func wrapMarkdownInline(line string, width int) []string {
return wrapInlineRunes(parseMarkdownInline(line), width)
}
func wrapInlineRunes(runes []markdownInlineRune, width int) []string {
if len(runes) == 0 {
return []string{""}
}
var rendered []string
for len(runes) > 0 {
hardCut, spaceCut, currentWidth := 0, 0, 0
for i, item := range runes {
nextWidth := currentWidth + runewidth.RuneWidth(item.r)
if nextWidth > width {
break
}
currentWidth = nextWidth
hardCut = i + 1
if unicode.IsSpace(item.r) && currentWidth > width/2 {
spaceCut = i
}
}
cut := hardCut
if spaceCut > 0 {
cut = spaceCut
}
if cut == 0 {
cut = 1
}
lineRunes := trimMarkdownSpace(runes[:cut])
rendered = append(rendered, renderMarkdownRunes(lineRunes))
runes = trimMarkdownSpace(runes[cut:])
}
return rendered
}
func parseMarkdownInline(line string) []markdownInlineRune {
var out []markdownInlineRune
for len(line) > 0 {
if strings.HasPrefix(line, "`") {
if end := strings.Index(line[1:], "`"); end >= 0 {
out = appendMarkdownRunes(out, line[1:end+1], markdownCode)
line = line[end+2:]
continue
}
}
if (strings.HasPrefix(line, "**") || strings.HasPrefix(line, "__")) && canOpenMarkdownStrong(out) {
delimiter := line[:2]
if end := strings.Index(line[2:], delimiter); end >= 0 {
out = appendMarkdownRunes(out, line[2:end+2], markdownStrong)
line = line[end+4:]
continue
}
}
r, size := utf8.DecodeRuneInString(line)
out = append(out, markdownInlineRune{r: r, style: markdownPlain})
line = line[size:]
}
return out
}
// canOpenMarkdownStrong keeps delimiter-like text in bare URLs and identifiers
// literal, only treating ** / __ as strong emphasis at the common
// whitespace- or punctuation-delimited form.
func canOpenMarkdownStrong(out []markdownInlineRune) bool {
if len(out) == 0 {
return true
}
previous := out[len(out)-1].r
return (unicode.IsSpace(previous) || unicode.IsPunct(previous)) && !markdownStrongInURL(out)
}
func markdownStrongInURL(out []markdownInlineRune) bool {
start := len(out)
for start > 0 && !unicode.IsSpace(out[start-1].r) {
start--
}
var token strings.Builder
for _, item := range out[start:] {
token.WriteRune(item.r)
}
return strings.Contains(token.String(), "://")
}
func appendMarkdownRunes(out []markdownInlineRune, text string, style markdownInlineStyle) []markdownInlineRune {
for _, r := range text {
out = append(out, markdownInlineRune{r: r, style: style})
}
return out
}
func trimMarkdownSpace(runes []markdownInlineRune) []markdownInlineRune {
start, end := 0, len(runes)
for start < end && unicode.IsSpace(runes[start].r) {
start++
}
for end > start && unicode.IsSpace(runes[end-1].r) {
end--
}
return runes[start:end]
}
func renderMarkdownRunes(runes []markdownInlineRune) string {
var b strings.Builder
for {
before, rest, ok := strings.Cut(line, "`")
b.WriteString(before)
if !ok {
break
for start := 0; start < len(runes); {
end := start + 1
for end < len(runes) && runes[end].style == runes[start].style {
end++
}
code, after, ok := strings.Cut(rest, "`")
if !ok {
b.WriteString("`")
b.WriteString(rest)
break
var text strings.Builder
for _, item := range runes[start:end] {
text.WriteRune(item.r)
}
b.WriteString(chatInlineCodeStyle.Render(code))
line = after
switch runes[start].style {
case markdownStrong:
b.WriteString(chatStrongStyle.Render(text.String()))
case markdownCode:
b.WriteString(chatInlineCodeStyle.Render(text.String()))
default:
b.WriteString(text.String())
}
start = end
}
return b.String()
}
@@ -130,7 +257,7 @@ func renderMarkdownTable(lines []string, width int) ([]string, int) {
if i < len(row) {
cell = row[i]
}
naturalWidths[i] = max(naturalWidths[i], lipglossWidth(cell))
naturalWidths[i] = max(naturalWidths[i], markdownInlineWidth(cell))
}
}
widths := markdownTableColumnWidths(naturalWidths, width)
@@ -232,19 +359,21 @@ func sumInts(values []int) int {
}
func wrapMarkdownTableCell(cell string, width int) []string {
width = max(1, width)
var out []string
line := strings.TrimSpace(cell)
for lipglossWidth(line) > width {
cut := chatDisplayWidthCut(line, width)
out = append(out, strings.TrimSpace(line[:cut]))
line = strings.TrimSpace(line[cut:])
}
out = append(out, line)
if len(out) == 0 {
lines := wrapInlineRunes(parseMarkdownInline(cell), max(1, width))
if len(lines) == 0 {
return []string{""}
}
return out
return lines
}
// markdownInlineWidth reports the visible width of a cell once Markdown
// delimiters are parsed away, so columns size to rendered content.
func markdownInlineWidth(cell string) int {
width := 0
for _, item := range parseMarkdownInline(cell) {
width += runewidth.RuneWidth(item.r)
}
return width
}
func looksLikeMarkdownTableRow(line string) bool {
-12
View File
@@ -1435,18 +1435,6 @@ func (m *chatModel) updateContextWindowTokens(tokens int) {
}
}
func (m chatModel) responseModelName(response *api.ChatResponse) string {
if response != nil {
if strings.TrimSpace(response.Model) != "" {
return response.Model
}
if strings.TrimSpace(response.RemoteModel) != "" {
return response.RemoteModel
}
}
return m.opts.Model
}
func (m chatModel) currentWorkingDir() string {
if strings.TrimSpace(m.workingDir) != "" {
return m.workingDir
+105 -1
View File
@@ -738,6 +738,107 @@ func TestChatStreamingAssistantOutputHoldsLiveMarkdown(t *testing.T) {
}
}
func TestChatStreamingRendersBoldBareURLAfterCompletion(t *testing.T) {
const response = "Draft PR opened: **https://github.com/ollama/ollama/pull/17203**"
m := chatModel{width: 80, height: 12, running: true, events: make(chan tea.Msg)}
updated, _ := m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "Draft PR opened: **https://github.com/ollama/"}})
m = updated.(chatModel)
if got := stripANSI(m.renderTranscript(80)); !strings.Contains(got, "**https://github.com/ollama/") {
t.Fatalf("incomplete Markdown should remain visible while streaming: %q", got)
}
updated, _ = m.Update(chatAgentMsg{event: coreagent.Event{Type: coreagent.EventMessageDelta, Content: "ollama/pull/17203**"}})
m = updated.(chatModel)
if got := m.entries[0].content; got != response {
t.Fatalf("streamed content = %q, want %q", got, response)
}
rendered := m.renderTranscript(80)
plain := stripANSI(rendered)
if strings.Contains(plain, "**") {
t.Fatalf("rendered response should not contain Markdown delimiters: %q", plain)
}
if !strings.Contains(plain, "Draft PR opened: https://github.com/ollama/ollama/pull/17203") {
t.Fatalf("rendered response missing URL: %q", plain)
}
if !strings.Contains(rendered, chatStrongStyle.Render("https://github.com/ollama/ollama/pull/17203")) {
t.Fatalf("URL should use the bold terminal style: %q", rendered)
}
}
func TestRenderMarkdownInlineWrapsStrongTextWithoutDelimiters(t *testing.T) {
rendered := renderMarkdownForView("**alpha beta gamma delta epsilon**", 20)
plain := stripANSI(rendered)
if strings.Contains(plain, "**") {
t.Fatalf("wrapped strong text should not contain Markdown delimiters: %q", plain)
}
for _, line := range strings.Split(rendered, "\n") {
if got := lipgloss.Width(line); got > 20 {
t.Fatalf("rendered line width = %d, want <= 20: %q", got, line)
}
}
}
func TestRenderMarkdownPreservesBareURLUnderscores(t *testing.T) {
const url = "https://example.com/a__b__"
if got := stripANSI(renderMarkdownForView(url, 80)); got != url {
t.Fatalf("bare URL = %q, want %q", got, url)
}
}
func TestRenderMarkdownStrongAfterPunctuation(t *testing.T) {
for _, test := range []struct {
name string
input string
want string
emphasis string
}{
{
name: "colon",
input: "Status: **ready**",
want: "Status: ready",
emphasis: "ready",
},
{
name: "dash",
input: "Note-**important**",
want: "Note-important",
emphasis: "important",
},
{
name: "closing parenthesis",
input: "Result) **complete**",
want: "Result) complete",
emphasis: "complete",
},
{
name: "identifier",
input: "value__with_delimiters__",
want: "value__with_delimiters__",
},
{
name: "URL",
input: "https://example.com/a__b__",
want: "https://example.com/a__b__",
},
{
name: "URL punctuation",
input: "https://example.com/a-**b**",
want: "https://example.com/a-**b**",
},
} {
t.Run(test.name, func(t *testing.T) {
rendered := renderMarkdownForView(test.input, 80)
if got := stripANSI(rendered); got != test.want {
t.Fatalf("rendered = %q, want %q", got, test.want)
}
if test.emphasis != "" && !strings.Contains(rendered, chatStrongStyle.Render(test.emphasis)) {
t.Fatalf("rendered output should emphasize %q: %q", test.emphasis, rendered)
}
})
}
}
func TestChatMouseWheelScrollsTranscriptWhileRunning(t *testing.T) {
m := chatModel{
width: 80,
@@ -1881,7 +1982,10 @@ func TestChatToolCallRendersPrettyInvocationAndResult(t *testing.T) {
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyCtrlO})
m = updated.(chatModel)
view := stripANSI(m.renderTranscript(100))
if !strings.Contains(view, "**Search results for:**") || !strings.Contains(view, "https://parthsareen.com") {
if strings.Contains(view, "**") {
t.Fatalf("inline web output should render Markdown, not show delimiters: %q", view)
}
if !strings.Contains(view, "Search results for:") || !strings.Contains(view, "https://parthsareen.com") {
t.Fatalf("inline web output missing content: %q", view)
}
}
+3
View File
@@ -45,6 +45,9 @@ var (
chatInlineCodeStyle = lipgloss.NewStyle().
Bold(true)
chatStrongStyle = lipgloss.NewStyle().
Bold(true)
chatCodeBlockStyle = lipgloss.NewStyle()
chatTableBorderStyle = lipgloss.NewStyle().
+2
View File
@@ -1198,6 +1198,8 @@ If you are creating a model from a safetensors directory or from a GGUF file, yo
- `files`: (optional) a dictionary of file names to SHA256 digests of blobs to create the model from
- `adapters`: (optional) a dictionary of file names to SHA256 digests of blobs for LORA adapters
- `template`: (optional) the prompt template for the model
- `renderer`: (optional) the name of the renderer for the model
- `parser`: (optional) the name of the parser for the model
- `license`: (optional) a string or list of strings containing the license or licenses for the model
- `system`: (optional) a string containing the system prompt for the model
- `parameters`: (optional) a dictionary of parameters for the model (see [Modelfile](./modelfile.mdx#valid-parameters-and-values) for a list of parameters)
+11 -1
View File
@@ -14,7 +14,7 @@ ollama launch hermes-desktop
Ollama handles the setup flow automatically:
1. **Install** - If Hermes Desktop isn't installed, Ollama prompts to install it
1. **Install** - If Hermes isn't installed, Ollama prompts to install the Hermes command-line agent. On first desktop launch, Hermes builds its packaged desktop app.
2. **Model** - Pick a model from the selector
3. **Configure** - Ollama configures Hermes Desktop to use your selected Ollama model
4. **Launch** - Ollama opens Hermes Desktop
@@ -26,3 +26,13 @@ ollama launch hermes-desktop --model <model>
```
Run `ollama launch hermes-desktop` again to switch models later.
## Install Hermes Desktop directly
On macOS and Windows, the Hermes Desktop installer is the recommended upstream installation path. It installs the desktop app and Hermes Agent together. If you prefer the command line, `ollama launch hermes-desktop` remains the explicit Ollama-managed path and uses the same Hermes configuration, sessions, skills, and memory as the CLI.
To force Hermes to rebuild its packaged desktop app:
```bash
ollama launch hermes-desktop -- --force-build
```
+4 -5
View File
@@ -14,7 +14,7 @@ ollama launch hermes
Ollama handles everything automatically:
1. **Install** — If Hermes isn't installed, Ollama prompts to install it via the Nous Research install script
1. **Install** — If Hermes isn't installed, Ollama prompts to install the Hermes command-line agent
2. **Model** — Pick a model from the selector (local or cloud)
3. **Onboarding** — Ollama configures the Ollama provider, points Hermes at `http://127.0.0.1:11434/v1`, and sets your model as the primary
4. **Gateway** — Optionally connects a messaging platform (Telegram, Discord, Slack, WhatsApp, Signal, Email) and launches the Hermes chat
@@ -45,10 +45,10 @@ hermes gateway setup
## Reconfigure
Re-run the full setup wizard at any time:
Use Hermes's model picker to change providers or models later:
```bash
hermes setup
hermes model
```
## Manual setup
@@ -106,7 +106,7 @@ Optionally connect a messaging platform during setup:
Connect a messaging platform? (Telegram, Discord, etc.)
→ Set up messaging now (recommended)
Skip — set up later with 'hermes setup gateway'
Skip — set up later with 'hermes gateway setup'
```
### Launch
@@ -114,4 +114,3 @@ Connect a messaging platform? (Telegram, Discord, etc.)
```
Launch hermes chat now? [Y/n]: Y
```
+6
View File
@@ -483,6 +483,12 @@ components:
template:
type: string
description: Prompt template to use for the model
renderer:
type: string
description: Name of the renderer for the model
parser:
type: string
description: Name of the parser for the model
license:
oneOf:
- type: string
+16 -3
View File
@@ -2,8 +2,21 @@
This directory contains integration tests to exercise Ollama end-to-end to verify behavior
By default, these tests are disabled so `go test ./...` will exercise only unit tests. To run integration tests you must pass the integration tag. `go test -tags=integration ./...` Some tests require additional tags to enable to allow scoped testing to keep the duration reasonable. For example, testing a broad set of models requires `-tags=integration,models` and a longer timeout (~60m or more depending on the speed of your GPU.). To view the current set of tag combinations use `find integration -type f | xargs grep "go:build"`
By default, these tests are disabled so `go test ./...` will exercise only unit tests. To run integration tests, pass the `integration` tag and one of the scoped tags:
```bash
go test -tags=integration,fast -v -count 1 ./integration/
go test -tags=integration,release -v -count 1 -timeout 30m ./integration/
go test -tags=integration,library -v -count 1 -timeout 120m ./integration/
```
Tags:
- `fast`: quick runner/model smoke coverage.
- `release`: release regression coverage.
- `library`: broad library coverage requiring about 2.5 TiB of disk space.
Scope wiring and model selections live in `integration/reg_fast_test.go`, `integration/reg_release_test.go`, and `integration/reg_library_test.go`.
The integration tests have 2 modes of operating.
@@ -21,12 +34,12 @@ harness starts the server.
## Testing a New Model
When implementing new model architecture, use `OLLAMA_TEST_MODEL` to run the
integration suite against your model.
integration suite against your model with either the `fast` or `release` coverage.
```bash
# Build the binary first
go build .
# Run integration tests against it
OLLAMA_TEST_MODEL=mymodel go test -tags integration -v -count 1 -timeout 15m ./integration/
OLLAMA_TEST_MODEL=mymodel go test -tags=integration,fast -v -count 1 ./integration/
```
+48 -29
View File
@@ -14,6 +14,13 @@ import (
"github.com/ollama/ollama/api"
)
const (
apiTestTimeout = 4 * time.Minute
apiInitialResponseTimeout = time.Minute
apiOverrideInitialResponseTimeout = 2 * time.Minute
apiStreamResponseTimeout = 30 * time.Second
)
func assertBytesMatchToken(t *testing.T, label, token string, ints []int) {
t.Helper()
@@ -31,10 +38,12 @@ func assertBytesMatchToken(t *testing.T, label, token string, ints []int) {
}
}
func TestAPIGenerate(t *testing.T) {
initialTimeout := 60 * time.Second
streamTimeout := 30 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
func runAPIGenerate(t *testing.T) {
initialTimeout := apiInitialResponseTimeout
if testModel != "" {
initialTimeout = apiOverrideInitialResponseTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
// Set up the test data
req := api.GenerateRequest{
@@ -45,7 +54,6 @@ func TestAPIGenerate(t *testing.T) {
"seed": 123,
},
}
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
@@ -105,7 +113,7 @@ func TestAPIGenerate(t *testing.T) {
} // else incremental response, nothing to check right now...
buf.Write([]byte(response.Response))
if !stallTimer.Reset(streamTimeout) {
if !stallTimer.Reset(apiStreamResponseTimeout) {
return fmt.Errorf("stall was detected while streaming response, aborting")
}
return nil
@@ -188,10 +196,12 @@ func TestAPIGenerate(t *testing.T) {
}
}
func TestAPIChat(t *testing.T) {
initialTimeout := 60 * time.Second
streamTimeout := 30 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
func runAPIChat(t *testing.T) {
initialTimeout := apiInitialResponseTimeout
if testModel != "" {
initialTimeout = apiOverrideInitialResponseTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
// Set up the test data
req := api.ChatRequest{
@@ -207,7 +217,6 @@ func TestAPIChat(t *testing.T) {
"seed": 123,
},
}
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
@@ -265,7 +274,7 @@ func TestAPIChat(t *testing.T) {
}
} // else incremental response, nothing to check right now...
buf.Write([]byte(response.Message.Content))
if !stallTimer.Reset(streamTimeout) {
if !stallTimer.Reset(apiStreamResponseTimeout) {
return fmt.Errorf("stall was detected while streaming response, aborting")
}
return nil
@@ -310,11 +319,11 @@ func TestAPIChat(t *testing.T) {
}
}
func TestAPIListModels(t *testing.T) {
func runAPIListModels(t *testing.T) {
if testModel != "" {
t.Skip("skipping metadata test with model override")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
@@ -331,44 +340,54 @@ func TestAPIListModels(t *testing.T) {
if len(resp.Models) == 0 {
t.Fatalf("list should not be empty")
}
model := resp.Models[0]
var model *api.ListModelResponse
for i := range resp.Models {
if resp.Models[i].Name == smol || resp.Models[i].Model == smol || strings.Contains(resp.Models[i].Name, smol) || strings.Contains(resp.Models[i].Model, smol) {
model = &resp.Models[i]
break
}
}
if model == nil {
t.Fatalf("list should include pulled model %s: %#v", smol, resp.Models)
}
if model.Name == "" {
t.Errorf("first model name empty: %#v", model)
t.Errorf("model name empty: %#v", model)
}
var nilTime time.Time
if model.ModifiedAt == nilTime {
t.Errorf("first model modified_at empty: %#v", model)
t.Errorf("model modified_at empty: %#v", model)
}
if model.Size == 0 {
t.Errorf("first model size empty: %#v", model)
t.Errorf("model size empty: %#v", model)
}
if model.Digest == "" {
t.Errorf("first model digest empty: %#v", model)
t.Errorf("model digest empty: %#v", model)
}
verifyModelDetails(t, model.Details)
}
func verifyModelDetails(t *testing.T, details api.ModelDetails) {
if details.Format == "" {
t.Errorf("first model details.format empty: %#v", details)
t.Errorf("model details.format empty: %#v", details)
}
if details.Family == "" {
t.Errorf("first model details.family empty: %#v", details)
t.Errorf("model details.family empty: %#v", details)
}
if details.ParameterSize == "" {
t.Errorf("first model details.parameter_size empty: %#v", details)
t.Errorf("model details.parameter_size empty: %#v", details)
}
if details.QuantizationLevel == "" {
t.Errorf("first model details.quantization_level empty: %#v", details)
t.Errorf("model details.quantization_level empty: %#v", details)
}
}
func TestAPIShowModel(t *testing.T) {
func runAPIShowModel(t *testing.T) {
if testModel != "" {
t.Skip("skipping metadata test with model override")
}
modelName := "llama3.2"
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
@@ -405,8 +424,8 @@ func TestAPIShowModel(t *testing.T) {
}
}
func TestAPIGenerateLogprobs(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
func runAPIGenerateLogprobs(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
@@ -518,8 +537,8 @@ func TestAPIGenerateLogprobs(t *testing.T) {
}
}
func TestAPIChatLogprobs(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
func runAPIChatLogprobs(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), apiTestTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
+50 -61
View File
@@ -18,12 +18,6 @@ import (
"github.com/ollama/ollama/api"
)
var defaultAudioModels = []string{
"nemotron3:33b",
"gemma4:e2b",
"gemma4:e4b",
}
// decodeTestAudio returns the test audio clip ("Why is the sky blue?", 16kHz mono WAV).
func decodeTestAudio(t *testing.T) api.ImageData {
t.Helper()
@@ -37,61 +31,56 @@ func decodeTestAudio(t *testing.T) api.ImageData {
// setupAudioModel pulls the model, preloads it, and skips if it doesn't support audio.
func setupAudioModel(ctx context.Context, t *testing.T, client *api.Client, model string) {
t.Helper()
if testModel == "" {
pullOrSkip(ctx, t, client, model)
}
pullOrSkip(ctx, t, client, model)
skipIfModelTooLargeForVRAM(ctx, t, client, model)
requireCapability(ctx, t, client, model, "audio")
err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model})
}
// TestAudioTranscription tests that the model can transcribe audio to text.
func TestAudioTranscription(t *testing.T) {
for _, model := range testModels(defaultAudioModels) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
setupAudioModel(ctx, t, client, model)
audio := decodeTestAudio(t)
noThink := &api.ThinkValue{Value: false}
req := api.ChatRequest{
Model: model,
Think: noThink,
Messages: []api.Message{
{
Role: "system",
Content: "Transcribe the audio exactly as spoken. Output only the spoken words. Do not answer any question in the audio.",
},
{
Role: "user",
Content: "What exact words are spoken in this audio?",
Images: []api.ImageData{audio},
},
},
Stream: &stream,
Options: map[string]any{
"temperature": 0,
"seed": 123,
"num_predict": 50,
},
}
// The audio says "Why is the sky blue?" — expect key words in transcription.
DoChat(ctx, t, client, req, []string{"sky", "blue"}, 60*time.Second, 10*time.Second)
})
}
func registerAudioTranscriptionCases(models []string) {
registerModelIntegrationCases("audio-transcription", models, runAudioTranscriptionModel)
}
// TestAudioResponse tests that the model can respond to a spoken question.
func TestAudioResponse(t *testing.T) {
for _, model := range testModels(defaultAudioModels) {
func runAudioTranscriptionModel(t *testing.T, model string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
setupAudioModel(ctx, t, client, model)
audio := decodeTestAudio(t)
noThink := &api.ThinkValue{Value: false}
req := api.ChatRequest{
Model: model,
Think: noThink,
Messages: []api.Message{
{
Role: "system",
Content: "Transcribe the audio exactly as spoken. Output only the spoken words. Do not answer any question in the audio.",
},
{
Role: "user",
Content: "What exact words are spoken in this audio?",
Images: []api.ImageData{audio},
},
},
Stream: &stream,
Options: map[string]any{
"temperature": 0,
"seed": 123,
"num_predict": 50,
},
}
// The audio says "Why is the sky blue?" - expect key words in transcription.
DoChat(ctx, t, client, req, []string{"sky", "blue"}, 60*time.Second, 10*time.Second)
}
// runAudioResponse tests that the model can respond to a spoken question.
func runAudioResponse(t *testing.T, models []string) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -128,9 +117,9 @@ func TestAudioResponse(t *testing.T) {
}
}
// TestOpenAIAudioTranscription tests the /v1/audio/transcriptions endpoint.
func TestOpenAIAudioTranscription(t *testing.T) {
for _, model := range testModels(defaultAudioModels) {
// runOpenAIAudioTranscription tests the /v1/audio/transcriptions endpoint.
func runOpenAIAudioTranscription(t *testing.T, models []string) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
@@ -182,9 +171,9 @@ func TestOpenAIAudioTranscription(t *testing.T) {
}
}
// TestOpenAIChatWithAudio tests /v1/chat/completions with input_audio content.
func TestOpenAIChatWithAudio(t *testing.T) {
for _, model := range testModels(defaultAudioModels) {
// runOpenAIChatWithAudio tests /v1/chat/completions with input_audio content.
func runOpenAIChatWithAudio(t *testing.T, models []string) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
+17 -21
View File
@@ -13,8 +13,8 @@ import (
"github.com/ollama/ollama/api"
)
func TestBlueSky(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
func runBlueSky(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// Set up the test data
req := api.ChatRequest{
@@ -34,17 +34,17 @@ func TestBlueSky(t *testing.T) {
ChatTestHelper(ctx, t, req, blueSkyExpected)
}
func TestUnicode(t *testing.T) {
func runUnicode(t *testing.T, model string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
skipUnderMinVRAM(t, 12) // Actual model load is ~26G
skipRegisteredMinVRAM(t, model)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// Set up the test data
req := api.ChatRequest{
// DeepSeek has a Unicode tokenizer regex, making it a unicode torture test
Model: "deepseek-coder-v2:16b-lite-instruct-q2_K", // TODO is there an ollama-engine model we can switch to and keep the coverage?
Model: model, // TODO is there an ollama-engine model we can switch to and keep the coverage?
Messages: []api.Message{
{
Role: "user",
@@ -63,11 +63,7 @@ func TestUnicode(t *testing.T) {
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
slog.Info("loading", "model", req.Model)
err := client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", req.Model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model})
defer func() {
// best effort unload once we're done with the model
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
@@ -81,15 +77,15 @@ func TestUnicode(t *testing.T) {
}, 180*time.Second, 30*time.Second)
}
func TestExtendedUnicodeOutput(t *testing.T) {
func runExtendedUnicodeOutput(t *testing.T, model string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
// Set up the test data
req := api.ChatRequest{
Model: "gemma2:2b",
Model: model,
Messages: []api.Message{
{
Role: "user",
@@ -108,14 +104,14 @@ func TestExtendedUnicodeOutput(t *testing.T) {
DoChat(ctx, t, client, req, []string{"😀", "😊", "😁", "😂", "😄", "😃"}, 120*time.Second, 120*time.Second)
}
func TestUnicodeModelDir(t *testing.T) {
func runUnicodeModelDir(t *testing.T) {
// This is only useful for Windows with utf-16 characters, so skip this test for other platforms
if runtime.GOOS != "windows" {
t.Skip("Unicode test only applicable to windows")
}
// Only works for local testing
if os.Getenv("OLLAMA_TEST_EXISTING") != "" {
t.Skip("TestUnicodeModelDir only works for local testing, skipping")
t.Skip("runUnicodeModelDir only works for local testing, skipping")
}
modelDir, err := os.MkdirTemp("", "ollama_埃")
@@ -127,7 +123,7 @@ func TestUnicodeModelDir(t *testing.T) {
t.Setenv("OLLAMA_MODELS", modelDir)
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
req := api.ChatRequest{
@@ -147,22 +143,22 @@ func TestUnicodeModelDir(t *testing.T) {
ChatTestHelper(ctx, t, req, blueSkyExpected)
}
// TestNumPredict verifies that when num_predict is set, the model generates
// runNumPredict verifies that when num_predict is set, the model generates
// exactly that many tokens. It uses logprobs to count the actual tokens output.
func TestNumPredict(t *testing.T) {
func runNumPredict(t *testing.T, model string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, "qwen3:0.6b")
pullOrSkip(ctx, t, client, model)
req := api.GenerateRequest{
Model: "qwen3:0.6b",
Model: model,
Prompt: "Write a long story.",
Stream: &stream,
Logprobs: true,
+144
View File
@@ -0,0 +1,144 @@
//go:build integration
package integration
import (
"context"
"fmt"
"log/slog"
"os"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/format"
)
var sweepVRAMWarning sync.Once
func registerChatCases(models []string) {
registerModelIntegrationCases("chat", models, runChatModel)
}
func runChatModel(t *testing.T, model string) {
t.Helper()
softTimeout, hardTimeout := getTimeouts(t)
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
if time.Since(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
skipRegisteredMinVRAM(t, model)
requireCapability(ctx, t, client, model, "completion")
skipIfTargetArchitecture(ctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
initialTimeout := 120 * time.Second
streamTimeout := 30 * time.Second
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
defer func() {
client.Generate(ctx, &api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}()
gpuPercent := getGPUPercent(ctx, t, client, model)
if gpuPercent < 80 {
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
initialTimeout = 240 * time.Second
streamTimeout = 40 * time.Second
}
req, anyResp := chatModelRequest(model)
DoChat(ctx, t, client, req, anyResp, initialTimeout, streamTimeout)
}
func chatModelRequest(model string) (api.ChatRequest, []string) {
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: blueSkyPrompt,
},
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]any{
"temperature": 0.1,
"seed": 123,
},
}
anyResp := blueSkyExpected
// Special cases
if model == "duckdb-nsql" {
anyResp = []string{"select", "from"}
} else if model == "granite3-guardian" || model == "shieldgemma" || model == "llama-guard3" || model == "bespoke-minicheck" {
anyResp = []string{"yes", "no", "safe", "unsafe"}
} else if model == "openthinker" {
anyResp = []string{"plugin", "im_sep", "components", "function call"}
} else if model == "starcoder" || model == "starcoder2" || model == "magicoder" || model == "deepseek-coder" {
req.Messages[0].Content = "def fibonacci():"
anyResp = []string{"f(n)", "sequence", "n-1", "main()", "__main__", "while"}
}
return req, anyResp
}
func skipIfTargetArchitecture(ctx context.Context, t *testing.T, client *api.Client, model string) {
t.Helper()
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
if targetArch == "" {
return
}
resp, err := client.Show(ctx, &api.ShowRequest{Name: model})
if err != nil {
t.Fatalf("unable to show model: %s", err)
}
arch := resp.ModelInfo["general.architecture"].(string)
if arch != targetArch {
t.Skip(fmt.Sprintf("Skipping %s architecture %s != %s", model, arch, targetArch))
}
}
func skipIfModelTooLargeForSweepVRAM(ctx context.Context, t *testing.T, client *api.Client, model string) {
t.Helper()
s := os.Getenv("OLLAMA_MAX_VRAM")
if s == "" {
sweepVRAMWarning.Do(func() {
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
})
return
}
maxVram, err := strconv.ParseUint(s, 10, 64)
if err != nil {
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
}
resp, err := client.List(ctx)
if err != nil {
t.Fatalf("list models failed %v", err)
}
for _, m := range resp.Models {
if modelNameMatches(model, m.Name) && float32(m.Size)*1.2 > float32(maxVram) {
t.Skipf("model %s is too large for available VRAM: %s > %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
}
}
}
func modelNameMatches(model, name string) bool {
if name == model {
return true
}
return !strings.Contains(model, ":") && strings.HasPrefix(name, model+":")
}
+6 -17
View File
@@ -20,7 +20,7 @@ import (
)
// Send multiple requests in parallel (concurrently) to a single model and ensure responses are expected
func TestConcurrentChat(t *testing.T) {
func runConcurrentChat(t *testing.T) {
// Assumes all requests have the same model
req, resp := ChatRequests()
numParallel := int(envconfig.NumParallel() + 1)
@@ -31,16 +31,10 @@ func TestConcurrentChat(t *testing.T) {
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req[0].Model)
// Get the server running (if applicable) warm the model up with a single initial request
slog.Info("loading", "model", req[0].Model)
err := client.Generate(ctx,
&api.GenerateRequest{Model: req[0].Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", req[0].Model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req[0].Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
var wg sync.WaitGroup
r := rand.New(rand.NewSource(0))
@@ -66,7 +60,7 @@ func TestConcurrentChat(t *testing.T) {
// Stress the scheduler and attempt to load more models than will fit to cause thrashing
// This test will always load at least 2 models even on CPU based systems
func TestMultiModelStress(t *testing.T) {
func runMultiModelStress(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded models, not applicable with model override")
}
@@ -85,7 +79,6 @@ func TestMultiModelStress(t *testing.T) {
"llama3.2:1b",
"qwen3:0.6b",
"gemma2:2b",
"deepseek-r1:1.5b", // qwen2 arch
"gemma3:270m",
}
mediumModels := []string{
@@ -126,12 +119,8 @@ func TestMultiModelStress(t *testing.T) {
slog.Info("Loading models to find how many can fit in VRAM before overflowing")
chooseModels:
for i, model := range chosenModels {
req := &api.GenerateRequest{Model: model} // Leave KeepAlive unset so they stay loaded until the scheduler decides to unload them
slog.Info("loading", "model", model)
err = client.Generate(ctx, req, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", model, err)
}
// Leave KeepAlive unset so they stay loaded until the scheduler decides to unload them.
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model})
targetLoadCount++
if i > 0 {
models, err := client.ListRunning(ctx)
+36 -45
View File
@@ -14,7 +14,12 @@ import (
"github.com/ollama/ollama/api"
)
func TestLongInputContext(t *testing.T) {
const (
longInputTimeout = 2 * time.Minute
longInputModelOverrideTimeout = 3 * time.Minute
)
func runLongInputContext(t *testing.T) {
// Setting NUM_PARALLEL to 1 ensures the allocated context is exactly what
// we asked for and there is nothing extra that we could spill over into.
// Context shift happens after a prompt has been admitted to a slot. Initial
@@ -23,7 +28,11 @@ func TestLongInputContext(t *testing.T) {
// prompt while llama-server reports it as too large to admit.
t.Setenv("OLLAMA_NUM_PARALLEL", "1")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
timeout := longInputTimeout
if testModel != "" {
timeout = longInputModelOverrideTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
req := api.ChatRequest{
Model: smol,
@@ -79,7 +88,7 @@ func isContextLimitError(err string) bool {
strings.Contains(err, "too long"))
}
func TestContextExhaustion(t *testing.T) {
func runContextExhaustion(t *testing.T) {
// Setting NUM_PARALLEL to 1 ensures the allocated context is exactly what
// we asked for and there is nothing extra that we could spill over into
t.Setenv("OLLAMA_NUM_PARALLEL", "1")
@@ -128,11 +137,13 @@ func containsEmoji(s string) bool {
}
// Send multiple generate requests with prior context and ensure the response is coherant and expected
func TestParallelGenerateWithHistory(t *testing.T) {
func runParallelGenerateWithHistory(t *testing.T, modelName string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
// The Generate API's Context field (token array continuation) is not
// supported by all runners (e.g. MLX). Chat history works; this is
// the only generate-specific continuation path.
t.Skip("generate context continuation not supported by all runners")
}
modelName := "gpt-oss:20b"
req, resp := GenerateRequests()
numParallel := 2
iterLimit := 2
@@ -144,16 +155,10 @@ func TestParallelGenerateWithHistory(t *testing.T) {
defer cleanup()
initialTimeout := 120 * time.Second
streamTimeout := 20 * time.Second
prepareParallelHistoryModel(ctx, t, client, modelName)
// Get the server running (if applicable) warm the model up with a single initial request
slog.Info("loading", "model", modelName)
err := client.Generate(ctx,
&api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", modelName, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
gpuPercent := getGPUPercent(ctx, t, client, modelName)
if gpuPercent < 80 && gpuPercent > 50 {
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
@@ -190,7 +195,7 @@ func TestParallelGenerateWithHistory(t *testing.T) {
}
// Send generate requests with prior context and ensure the response is coherant and expected
func TestGenerateWithHistory(t *testing.T) {
func runGenerateWithHistory(t *testing.T) {
if testModel != "" {
// The Generate API's Context field (token array continuation) is not
// supported by all runners (e.g. MLX). Chat history works; this is
@@ -212,16 +217,10 @@ func TestGenerateWithHistory(t *testing.T) {
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
// Get the server running (if applicable) warm the model up with a single initial request
slog.Info("loading", "model", req.Model)
err := client.Generate(ctx,
&api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", req.Model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options})
req.Context = DoGenerate(ctx, t, client, req, rainbowExpected, 30*time.Second, 20*time.Second)
@@ -236,11 +235,7 @@ func TestGenerateWithHistory(t *testing.T) {
}
// Send multiple chat requests with prior context and ensure the response is coherant and expected
func TestParallelChatWithHistory(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
modelName := "gpt-oss:20b"
func runParallelChatWithHistory(t *testing.T, modelName string) {
req, resp := ChatRequests()
numParallel := 2
iterLimit := 2
@@ -252,16 +247,10 @@ func TestParallelChatWithHistory(t *testing.T) {
defer cleanup()
initialTimeout := 120 * time.Second
streamTimeout := 20 * time.Second
prepareParallelHistoryModel(ctx, t, client, modelName)
// Get the server running (if applicable) warm the model up with a single initial empty request
slog.Info("loading", "model", modelName)
err := client.Generate(ctx,
&api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", modelName, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: modelName, KeepAlive: &api.Duration{Duration: 10 * time.Second}})
gpuPercent := getGPUPercent(ctx, t, client, modelName)
if gpuPercent < 80 && gpuPercent > 50 {
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
@@ -302,8 +291,16 @@ func TestParallelChatWithHistory(t *testing.T) {
wg.Wait()
}
func prepareParallelHistoryModel(ctx context.Context, t *testing.T, client *api.Client, modelName string) {
t.Helper()
skipRegisteredMinVRAM(t, modelName)
requireCapability(ctx, t, client, modelName, "completion")
skipIfTargetArchitecture(ctx, t, client, modelName)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, modelName)
}
// Send generate requests with prior context and ensure the response is coherant and expected
func TestChatWithHistory(t *testing.T) {
func runChatWithHistory(t *testing.T) {
req := api.ChatRequest{
Model: smol,
Stream: &stream,
@@ -324,16 +321,10 @@ func TestChatWithHistory(t *testing.T) {
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
pullOrSkip(ctx, t, client, req.Model)
// Get the server running (if applicable) warm the model up with a single initial request
slog.Info("loading", "model", req.Model)
err := client.Generate(ctx,
&api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
t.Fatalf("failed to load model %s: %s", req.Model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 10 * time.Second}, Options: req.Options})
assistant := DoChat(ctx, t, client, req, rainbowExpected, 30*time.Second, 20*time.Second)
+2 -2
View File
@@ -136,7 +136,7 @@ func runOllamaCreate(ctx context.Context, t *testing.T, args ...string) {
}
}
func TestCreateSafetensorsLLM(t *testing.T) {
func runCreateSafetensorsLLM(t *testing.T) {
if testModel != "" {
t.Skip("exercises create pipeline with a fixed source model, not applicable with model override")
}
@@ -214,7 +214,7 @@ func TestCreateSafetensorsLLM(t *testing.T) {
}
}
func TestCreateGGUF(t *testing.T) {
func runCreateGGUF(t *testing.T) {
if testModel != "" {
t.Skip("exercises create pipeline with a fixed source model, not applicable with model override")
}
+204
View File
@@ -0,0 +1,204 @@
//go:build integration
package integration
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
)
func registerEmbeddingCases(models []string) {
registerEmbeddingCasesWithFallback(models, false)
}
func registerLibraryEmbeddingCases(models []string) {
registerEmbeddingCasesWithFallback(models, true)
}
func registerEmbeddingCasesWithFallback(models []string, smokeMissing bool) {
testCases, err := loadEmbeddingTestCases()
if err != nil {
registerIntegrationCases(integrationCase{
Key: "embed/testdata",
Case: "embed",
Model: "testdata",
Run: func(t *testing.T) {
t.Fatalf("failed to load embedding test data: %s", err)
},
})
return
}
if testModel != "" {
models = []string{testModel}
}
cases := make([]integrationCase, 0, len(models))
for _, model := range models {
model := model
expected, ok := embeddingExpected(testCases, model)
if !ok {
if smokeMissing || testModel != "" {
cases = append(cases, embeddingSmokeCase(model))
continue
}
cases = append(cases, integrationCase{
Key: "embed/" + model,
Case: "embed",
Model: model,
Run: func(t *testing.T) {
t.Skipf("no embedding expectation for model %s", model)
},
})
continue
}
cases = append(cases, embeddingCase(model, expected))
}
registerIntegrationCases(cases...)
}
func embeddingSmokeCase(model string) integrationCase {
return integrationCase{
Key: "embed/" + model,
Case: "embed",
Model: model,
Run: func(t *testing.T) {
runEmbeddingSmokeModel(t, model)
},
}
}
func embeddingCase(model string, expected []float64) integrationCase {
return integrationCase{
Key: "embed/" + model,
Case: "embed",
Model: model,
Run: func(t *testing.T) {
runEmbeddingModel(t, model, expected)
},
}
}
func loadEmbeddingTestCases() (map[string][]float64, error) {
data, err := os.ReadFile(filepath.Join("testdata", "embed.json"))
if err != nil {
return nil, err
}
testCases := map[string][]float64{}
if err := json.Unmarshal(data, &testCases); err != nil {
return nil, err
}
return testCases, nil
}
func embeddingExpected(testCases map[string][]float64, model string) ([]float64, bool) {
if expected, ok := testCases[model]; ok {
return expected, true
}
if !strings.Contains(model, ":") {
expected, ok := testCases[model+":latest"]
return expected, ok
}
return nil, false
}
func runEmbeddingModel(t *testing.T, model string, expected []float64) {
t.Helper()
softTimeout, hardTimeout := getTimeouts(t)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
if time.Since(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
req := api.EmbeddingRequest{
Model: model,
Prompt: "why is the sky blue?",
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]any{
"temperature": 0,
"seed": 123,
},
}
resp, err := client.Embeddings(ctx, &req)
if err != nil {
t.Fatalf("embeddings call failed %s", err)
}
defer func() {
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}()
if len(resp.Embedding) == 0 {
t.Errorf("zero length embedding response")
}
if len(expected) != len(resp.Embedding) {
expStr := make([]string, len(resp.Embedding))
for i, v := range resp.Embedding {
expStr[i] = fmt.Sprintf("%0.6f", v)
}
// When adding new models, use this output to populate the testdata/embed.json
fmt.Printf("expected\n%s\n", strings.Join(expStr, ", "))
t.Fatalf("expected %d, got %d", len(expected), len(resp.Embedding))
}
sim := cosineSimilarity(resp.Embedding, expected)
if sim < 0.99 {
t.Fatalf("expected %v, got %v (similarity: %f)", expected[0:5], resp.Embedding[0:5], sim)
}
}
func runEmbeddingSmokeModel(t *testing.T, model string) {
t.Helper()
softTimeout, hardTimeout := getTimeouts(t)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
if time.Since(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
requireCapability(ctx, t, client, model, "embedding")
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
req := api.EmbedRequest{
Model: model,
Input: []string{"cat", "kitten", "dog"},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
}
resp, err := embedTestHelper(ctx, client, t, req)
if err != nil {
t.Fatal(err)
}
defer func() {
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}()
if len(resp.Embeddings) != 3 {
t.Fatalf("expected 3 embeddings, got %d", len(resp.Embeddings))
}
for i, embedding := range resp.Embeddings {
if len(embedding) == 0 {
t.Fatalf("embedding %d was empty", i)
}
}
cosRelated := cosineSimilarity(resp.Embeddings[0], resp.Embeddings[1])
cosUnrelated := cosineSimilarity(resp.Embeddings[0], resp.Embeddings[2])
if cosRelated <= cosUnrelated {
t.Fatalf("expected related terms to be closer than unrelated terms: cat/kitten=%f cat/dog=%f", cosRelated, cosUnrelated)
}
}
+37 -24
View File
@@ -10,7 +10,6 @@ import (
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/api"
)
@@ -61,6 +60,19 @@ func requireEmbedErrorContainsAny(t *testing.T, err error, substrings ...string)
t.Fatalf("expected error containing one of %q, got: %v", substrings, err)
}
func requireSimilarEmbedding(t *testing.T, want, got []float32) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("expected %d embedding floats, got %d", len(want), len(got))
}
sim := cosineSimilarity(got, want)
if sim < 0.999 {
t.Fatalf("expected embedding similar to %v, got %v (similarity: %f)", want[0:5], got[0:5], sim)
}
}
func euclideanDistance[V float32 | float64](v1, v2 []V) V {
if len(v1) != len(v2) {
return V(math.Inf(1))
@@ -88,13 +100,13 @@ func manhattanDistance[V float32 | float64](v1, v2 []V) V {
return sum
}
func TestEmbedCosineDistanceCorrelation(t *testing.T) {
func runEmbedCosineDistanceCorrelation(t *testing.T, models []string) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
for _, model := range testModels(libraryEmbedModels) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
if testModel != "" {
requireCapability(ctx, t, client, model, "embedding")
@@ -163,7 +175,7 @@ func TestEmbedCosineDistanceCorrelation(t *testing.T) {
}
}
func TestAllMiniLMEmbeddings(t *testing.T) {
func runAllMiniLMEmbeddings(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -196,7 +208,7 @@ func TestAllMiniLMEmbeddings(t *testing.T) {
}
}
func TestAllMiniLMEmbed(t *testing.T) {
func runAllMiniLMEmbed(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -236,7 +248,7 @@ func TestAllMiniLMEmbed(t *testing.T) {
}
}
func TestAllMiniLMBatchEmbed(t *testing.T) {
func runAllMiniLMBatchEmbed(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -286,7 +298,7 @@ func TestAllMiniLMBatchEmbed(t *testing.T) {
}
}
func TestAllMiniLMEmbedTruncate(t *testing.T) {
func runAllMiniLMEmbedTruncate(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -321,9 +333,7 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
t.Fatal(err)
}
if diff := cmp.Diff(want.Embeddings[0], got.Embeddings[0]); diff != "" {
t.Errorf("embedding mismatch (-want +got):\n%s", diff)
}
requireSimilarEmbedding(t, want.Embeddings[0], got.Embeddings[0])
},
},
{
@@ -338,9 +348,7 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
t.Fatal(err)
}
t.Logf("PromptEvalCount: want=%d got=%d", want.PromptEvalCount, got.PromptEvalCount)
if diff := cmp.Diff(want.Embeddings[0], got.Embeddings[0]); diff != "" {
t.Errorf("embedding mismatch (-want +got):\n%s", diff)
}
requireSimilarEmbedding(t, want.Embeddings[0], got.Embeddings[0])
},
},
{
@@ -356,9 +364,7 @@ func TestAllMiniLMEmbedTruncate(t *testing.T) {
t.Fatal(err)
}
t.Logf("PromptEvalCount: want=%d got=%d", want.PromptEvalCount, got.PromptEvalCount)
if diff := cmp.Diff(want.Embeddings[0], got.Embeddings[0]); diff != "" {
t.Errorf("embedding mismatch (-want +got):\n%s", diff)
}
requireSimilarEmbedding(t, want.Embeddings[0], got.Embeddings[0])
},
},
{
@@ -432,7 +438,7 @@ func embedTestHelper(ctx context.Context, client *api.Client, t *testing.T, req
return client.Embed(ctx, &req)
}
func TestEmbedTruncation(t *testing.T) {
func runEmbedTruncation(t *testing.T, models []string) {
// Use test deadline if set, otherwise default to 2 minutes
timeout := 2 * time.Minute
if deadline, ok := t.Deadline(); ok {
@@ -443,7 +449,7 @@ func TestEmbedTruncation(t *testing.T) {
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
for _, model := range testModels(libraryEmbedModels) {
for _, model := range testModels(models) {
model := model
t.Run(model, func(t *testing.T) {
if testModel != "" {
@@ -454,6 +460,9 @@ func TestEmbedTruncation(t *testing.T) {
t.Skip("skipping remaining tests to avoid timeout")
}
pullOrSkip(ctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
// Give each model its own budget to account for first-time pulls/loads
mctx, mcancel := context.WithTimeout(ctx, 3*time.Minute)
defer mcancel()
@@ -507,19 +516,22 @@ func TestEmbedTruncation(t *testing.T) {
}
}
// TestEmbedLargeInput tests that embedding models can handle large inputs that would exceed typical batch sizes.
func TestEmbedLargeInput(t *testing.T) {
// runEmbedLargeInput tests that embedding models can handle large inputs that would exceed typical batch sizes.
func runEmbedLargeInput(t *testing.T, models []string) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
for _, model := range testModels(libraryEmbedModels) {
for _, model := range testModels(models) {
model := model
t.Run(model, func(t *testing.T) {
if testModel != "" {
requireCapability(ctx, t, client, model, "embedding")
}
pullOrSkip(ctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(ctx, t, client, model)
mctx, mcancel := context.WithTimeout(ctx, 2*time.Minute)
defer mcancel()
@@ -567,11 +579,11 @@ func TestEmbedLargeInput(t *testing.T) {
}
}
// TestEmbedStatusCode tests that errors from the embedding endpoint
// runEmbedStatusCode tests that errors from the embedding endpoint
// properly preserve their HTTP status codes when returned to the client.
// This test specifically checks the error handling path in EmbedHandler
// where api.StatusError errors should maintain their original status code.
func TestEmbedStatusCode(t *testing.T) {
func runEmbedStatusCode(t *testing.T, models []string) {
// Use test deadline if set, otherwise default to 2 minutes
timeout := 2 * time.Minute
if deadline, ok := t.Deadline(); ok {
@@ -582,7 +594,7 @@ func TestEmbedStatusCode(t *testing.T) {
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
for _, model := range testModels(libraryEmbedModels) {
for _, model := range testModels(models) {
model := model
t.Run(model, func(t *testing.T) {
if testModel != "" {
@@ -598,6 +610,7 @@ func TestEmbedStatusCode(t *testing.T) {
// Pull the model if needed
pullOrSkip(mctx, t, client, model)
skipIfModelTooLargeForSweepVRAM(mctx, t, client, model)
t.Run("truncation error status code", func(t *testing.T) {
truncFalse := false
+3 -5
View File
@@ -13,7 +13,7 @@ import (
"github.com/ollama/ollama/api"
)
func TestImageGeneration(t *testing.T) {
func runImageGeneration(t *testing.T) {
if testModel != "" {
t.Skip("uses hardcoded models, not applicable with model override")
}
@@ -51,6 +51,7 @@ func TestImageGeneration(t *testing.T) {
t.Logf("Generating image with prompt: %s", tc.prompt)
imageBase64, err := generateImage(ctx, client, tc.imageGenModel, tc.prompt)
if err != nil {
skipIfMLXUnsupported(t, err)
if strings.Contains(err.Error(), "image generation not available") {
t.Skip("Target system does not support image generation")
} else if strings.Contains(err.Error(), "executable file not found in") { // Windows pattern, not yet supported
@@ -79,10 +80,7 @@ func TestImageGeneration(t *testing.T) {
t.Logf("Generated image: %d bytes", len(imageData))
// Preload vision model and check GPU loading
err = client.Generate(ctx, &api.GenerateRequest{Model: tc.visionModel}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load vision model: %v", err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: tc.visionModel})
// Use vision model to describe the image
chatReq := api.ChatRequest{
-72
View File
@@ -1,72 +0,0 @@
//go:build integration && library
package integration
import (
"context"
"fmt"
"log/slog"
"os"
"testing"
"time"
"github.com/ollama/ollama/api"
)
// First run of this scenario on a target system will take a long time to download
// ~1.5TB of models. Set a sufficiently large -timeout for your network speed
func TestLibraryModelsChat(t *testing.T) {
softTimeout, hardTimeout := getTimeouts(t)
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
for _, model := range testModels(libraryChatModels) {
t.Run(model, func(t *testing.T) {
if time.Now().Sub(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
if targetArch != "" {
resp, err := client.Show(ctx, &api.ShowRequest{Name: model})
if err != nil {
t.Fatalf("unable to show model: %s", err)
}
arch := resp.ModelInfo["general.architecture"].(string)
if arch != targetArch {
t.Skip(fmt.Sprintf("Skipping %s architecture %s != %s", model, arch, targetArch))
}
}
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: blueSkyPrompt,
},
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]interface{}{
"temperature": 0.1,
"seed": 123,
},
}
anyResp := blueSkyExpected
// Special cases
if model == "duckdb-nsql" {
anyResp = []string{"select", "from"}
} else if model == "granite3-guardian" || model == "shieldgemma" || model == "llama-guard3" || model == "bespoke-minicheck" {
anyResp = []string{"yes", "no", "safe", "unsafe"}
} else if model == "openthinker" {
anyResp = []string{"plugin", "im_sep", "components", "function call"}
} else if model == "starcoder" || model == "starcoder2" || model == "magicoder" || model == "deepseek-coder" {
req.Messages[0].Content = "def fibonacci():"
anyResp = []string{"f(n)", "sequence", "n-1", "main()", "__main__", "while"}
}
DoChat(ctx, t, client, req, anyResp, 120*time.Second, 30*time.Second)
})
}
}
+46 -62
View File
@@ -11,69 +11,53 @@ import (
"github.com/ollama/ollama/api"
)
func TestVisionModels(t *testing.T) {
skipUnderMinVRAM(t, 6)
defaultVisionModels := []string{
"gemma4",
"qwen2.5vl",
// "llama3.2-vision", // TODO: re-enable when llama.cpp supports mllama.
"gemma3",
"qwen3-vl:8b",
"qwen3-vl:30b",
"ministral-3",
}
skipIfNoVisionOverride(t)
for _, model := range testModels(defaultVisionModels) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
requireCapability(ctx, t, client, model, "vision")
pullOrSkip(ctx, t, client, model)
image, err := base64.StdEncoding.DecodeString(imageEncoding)
if err != nil {
t.Fatal(err)
}
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: "what does the text in this image say?",
Images: []api.ImageData{
image,
},
},
},
Stream: &stream,
Options: map[string]any{
"seed": 42,
"temperature": 0.0,
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
}
// Preload to skip if we're less than 80% on GPU to avoid extremely slow tests
err = client.Generate(ctx, &api.GenerateRequest{Model: req.Model}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", req.Model, err)
}
skipIfNotGPULoaded(ctx, t, client, req.Model, 80)
// Note: sometimes it returns "the ollamas" sometimes "the ollams"
// llava models on CPU can be quite slow to start
DoChat(ctx, t, client, req, []string{"the ollam"}, 240*time.Second, 30*time.Second)
})
}
func registerVisionTextCases(models []string) {
registerModelIntegrationCases("vision-text", models, runVisionTextModel)
}
func TestIntegrationSplitBatch(t *testing.T) {
func runVisionTextModel(t *testing.T, model string) {
t.Helper()
skipUnderMinVRAM(t, 6)
skipKnownIntegrationFlake(t, "vision-text", model)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
requireCapability(ctx, t, client, model, "vision")
pullOrSkip(ctx, t, client, model)
image, err := base64.StdEncoding.DecodeString(imageEncoding)
if err != nil {
t.Fatal(err)
}
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: "what does the text in this image say?",
Images: []api.ImageData{
image,
},
},
},
Stream: &stream,
Options: map[string]any{
"seed": 42,
"temperature": 0.0,
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
}
// Preload to skip if we're less than 80% on GPU to avoid extremely slow tests
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: req.Model})
skipIfNotGPULoaded(ctx, t, client, req.Model, 80)
DoChat(ctx, t, client, req, []string{"the ollam", "ollamas"}, 240*time.Second, 30*time.Second)
}
func runIntegrationSplitBatch(t *testing.T, model string) {
if testModel != "" {
t.Skip("uses hardcoded model, not applicable with model override")
}
@@ -83,7 +67,7 @@ func TestIntegrationSplitBatch(t *testing.T) {
t.Fatal(err)
}
req := api.GenerateRequest{
Model: "gemma3:4b",
Model: model,
// Fill up a chunk of the batch so the image will partially spill over into the next one
System: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed aliquet, justo in malesuada lobortis, odio ligula volutpat quam, quis faucibus ipsum magna quis sapien. Aliquam in venenatis diam, eu viverra magna. Phasellus imperdiet hendrerit volutpat. Vivamus sem ex, facilisis placerat felis non, dictum elementum est. Phasellus aliquam imperdiet lacus, eget placerat ligula sodales vel. Pellentesque nec auctor mi. Curabitur arcu nisi, faucibus eget nunc id, viverra interdum mi. Curabitur ornare ipsum ex, ac euismod ex aliquam in. Vestibulum id magna at purus accumsan fermentum. Proin scelerisque posuere nunc quis interdum. Maecenas sed mollis nisl. Etiam vitae ipsum interdum, placerat est quis, tincidunt velit. Nullam tempor nibh non lorem volutpat efficitur. Cras laoreet diam imperdiet ipsum auctor bibendum. Suspendisse ultrices urna sed metus sagittis suscipit. Quisque ullamcorper aliquam nibh ut mollis. Aenean dapibus mauris pharetra, venenatis elit ac, hendrerit odio. Cras vestibulum erat tempor, lobortis justo eu, lobortis ipsum. Nam laoreet dapibus sem. Proin vel diam ultrices, elementum ante et, ornare lectus. Proin eu accumsan nisl. Praesent ac ex vitae ipsum vulputate tristique facilisis sit amet lacus. Nullam faucibus magna a pellentesque pretium. Nunc lacinia ullamcorper sollicitudin. Donec vitae accumsan turpis, sed porttitor est. Donec porttitor mi vitae augue faucibus, vel mollis diam tincidunt.",
Prompt: "what does the text in this image say?",
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"github.com/ollama/ollama/api"
)
func TestMaxQueue(t *testing.T) {
func runMaxQueue(t *testing.T) {
t.Skip("this test needs to be re-evaluated to use a proper embedding model")
if os.Getenv("OLLAMA_TEST_EXISTING") != "" {
-187
View File
@@ -1,187 +0,0 @@
//go:build integration && models
package integration
import (
"context"
"encoding/json"
"fmt"
"io/ioutil"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/format"
)
func TestModelsChat(t *testing.T) {
softTimeout, hardTimeout := getTimeouts(t)
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
// TODO use info API eventually
var maxVram uint64
var err error
if s := os.Getenv("OLLAMA_MAX_VRAM"); s != "" {
maxVram, err = strconv.ParseUint(s, 10, 64)
if err != nil {
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
}
} else {
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
}
chatModels := append(ollamaEngineChatModels, llamaRunnerChatModels...)
chatModels = append(chatModels, mlxEngineChatModels...)
for _, model := range testModels(chatModels) {
t.Run(model, func(t *testing.T) {
if time.Now().Sub(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
if maxVram > 0 {
resp, err := client.List(ctx)
if err != nil {
t.Fatalf("list models failed %v", err)
}
for _, m := range resp.Models {
if m.Name == model && float32(m.Size)*1.2 > float32(maxVram) {
t.Skipf("model %s is too large for available VRAM: %s > %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
}
}
}
initialTimeout := 120 * time.Second
streamTimeout := 30 * time.Second
slog.Info("loading", "model", model)
err := client.Generate(ctx,
&api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 10 * time.Second}},
func(response api.GenerateResponse) error { return nil },
)
if err != nil {
skipIfMLXUnsupported(t, err)
t.Fatalf("failed to load model %s: %s", model, err)
}
gpuPercent := getGPUPercent(ctx, t, client, model)
if gpuPercent < 80 {
slog.Warn("Low GPU percentage - increasing timeouts", "percent", gpuPercent)
initialTimeout = 240 * time.Second
streamTimeout = 40 * time.Second
}
// TODO - fiddle with context size
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: blueSkyPrompt,
},
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]interface{}{
"temperature": 0,
"seed": 123,
},
}
DoChat(ctx, t, client, req, blueSkyExpected, initialTimeout, streamTimeout)
// best effort unload once we're done with the model
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
})
}
}
func TestModelsEmbed(t *testing.T) {
softTimeout, hardTimeout := getTimeouts(t)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
// TODO use info API eventually
var maxVram uint64
var err error
if s := os.Getenv("OLLAMA_MAX_VRAM"); s != "" {
maxVram, err = strconv.ParseUint(s, 10, 64)
if err != nil {
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
}
} else {
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
}
data, err := ioutil.ReadFile(filepath.Join("testdata", "embed.json"))
if err != nil {
t.Fatalf("failed to open test data file: %s", err)
}
testCase := map[string][]float64{}
err = json.Unmarshal(data, &testCase)
if err != nil {
t.Fatalf("failed to load test data: %s", err)
}
for model, expected := range testCase {
if testModel != "" && model != testModel {
continue
}
t.Run(model, func(t *testing.T) {
if time.Now().Sub(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
if maxVram > 0 {
resp, err := client.List(ctx)
if err != nil {
t.Fatalf("list models failed %v", err)
}
for _, m := range resp.Models {
if m.Name == model && float32(m.Size)*1.2 > float32(maxVram) {
t.Skipf("model %s is too large for available VRAM: %s > %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
}
}
}
req := api.EmbeddingRequest{
Model: model,
Prompt: "why is the sky blue?",
KeepAlive: &api.Duration{Duration: 10 * time.Second},
Options: map[string]interface{}{
"temperature": 0,
"seed": 123,
},
}
resp, err := client.Embeddings(ctx, &req)
if err != nil {
t.Fatalf("embeddings call failed %s", err)
}
defer func() {
// best effort unload once we're done with the model
client.Generate(ctx, &api.GenerateRequest{Model: req.Model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}()
if len(resp.Embedding) == 0 {
t.Errorf("zero length embedding response")
}
if len(expected) != len(resp.Embedding) {
expStr := make([]string, len(resp.Embedding))
for i, v := range resp.Embedding {
expStr[i] = fmt.Sprintf("%0.6f", v)
}
// When adding new models, use this output to populate the testdata/embed.json
fmt.Printf("expected\n%s\n", strings.Join(expStr, ", "))
t.Fatalf("expected %d, got %d", len(expected), len(resp.Embedding))
}
sim := cosineSimilarity(resp.Embedding, expected)
if sim < 0.99 {
t.Fatalf("expected %v, got %v (similarity: %f)", expected[0:5], resp.Embedding[0:5], sim)
}
})
}
}
-278
View File
@@ -1,278 +0,0 @@
//go:build integration && perf
package integration
import (
"context"
"fmt"
"io/ioutil"
"log/slog"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/format"
)
var (
// Models that don't work reliably with the large context prompt in this test case
longContextFlakes = []string{
"granite-code:latest",
"nemotron-mini:latest",
"falcon:latest", // 2k model
"falcon2:latest", // 2k model
"minicpm-v:latest",
"qwen:latest",
}
)
// Note: this test case can take a long time to run, particularly on models with
// large contexts. Run with -timeout set to a large value to get reasonable coverage
// Example usage:
//
// go test --tags=integration,perf -count 1 ./integration -v -timeout 90m -run TestModelsPerf 2>&1 | tee int.log
// cat int.log | grep MODEL_PERF_HEADER | head -1| cut -f2- -d: > perf.csv
// cat int.log | grep MODEL_PERF_DATA | cut -f2- -d: >> perf.csv
func TestModelsPerf(t *testing.T) {
doModelPerfTest(t, append(ollamaEngineChatModels, llamaRunnerChatModels...))
}
func TestLibraryModelsPerf(t *testing.T) {
doModelPerfTest(t, libraryChatModels)
}
func doModelPerfTest(t *testing.T, chatModels []string) {
softTimeout, hardTimeout := getTimeouts(t)
slog.Info("Setting timeouts", "soft", softTimeout, "hard", hardTimeout)
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
// TODO use info API eventually
var maxVram uint64
var err error
if s := os.Getenv("OLLAMA_MAX_VRAM"); s != "" {
maxVram, err = strconv.ParseUint(s, 10, 64)
if err != nil {
t.Fatalf("invalid OLLAMA_MAX_VRAM %v", err)
}
} else {
slog.Warn("No VRAM info available, testing all models, so larger ones might timeout...")
}
data, err := ioutil.ReadFile(filepath.Join("testdata", "shakespeare.txt"))
if err != nil {
t.Fatalf("failed to open test data file: %s", err)
}
longPrompt := "summarize the following: " + string(data)
targetArch := os.Getenv("OLLAMA_TEST_ARCHITECTURE")
for _, model := range chatModels {
if !strings.Contains(model, ":") {
model = model + ":latest"
}
t.Run(model, func(t *testing.T) {
if time.Now().Sub(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
pullOrSkip(ctx, t, client, model)
var maxContext int
resp, err := client.Show(ctx, &api.ShowRequest{Model: model})
if err != nil {
t.Fatalf("show failed: %s", err)
}
arch := resp.ModelInfo["general.architecture"].(string)
maxContext = int(resp.ModelInfo[fmt.Sprintf("%s.context_length", arch)].(float64))
if targetArch != "" && arch != targetArch {
t.Skip(fmt.Sprintf("Skipping %s architecture %s != %s", model, arch, targetArch))
}
if maxVram > 0 {
resp, err := client.List(ctx)
if err != nil {
t.Fatalf("list models failed %v", err)
}
for _, m := range resp.Models {
// For these tests we want to exercise a some amount of overflow on the CPU
if m.Name == model && float32(m.Size)*0.75 > float32(maxVram) {
t.Skipf("model %s is too large %s for available VRAM %s", model, format.HumanBytes(m.Size), format.HumanBytes(int64(maxVram)))
}
}
}
slog.Info("scneario", "model", model, "max_context", maxContext)
loaded := false
defer func() {
// best effort unload once we're done with the model
if loaded {
client.Generate(ctx, &api.GenerateRequest{Model: model, KeepAlive: &api.Duration{Duration: 0}}, func(rsp api.GenerateResponse) error { return nil })
}
}()
// Some models don't handle the long context data well so skip them to avoid flaky test results
longContextFlake := false
for _, flake := range longContextFlakes {
if model == flake {
longContextFlake = true
break
}
}
// iterate through a few context sizes for coverage without excessive runtime
var contexts []int
keepGoing := true
if maxContext > 16384 {
contexts = []int{4096, 8192, 16384, maxContext}
} else if maxContext > 8192 {
contexts = []int{4096, 8192, maxContext}
} else if maxContext > 4096 {
contexts = []int{4096, maxContext}
} else if maxContext > 0 {
contexts = []int{maxContext}
} else {
t.Fatal("unknown max context size")
}
for _, numCtx := range contexts {
if !keepGoing && numCtx > 8192 { // Always try up to 8k before bailing out
break
}
skipLongPrompt := false
// Workaround bug 11172 temporarily...
maxPrompt := longPrompt
// If we fill the context too full with the prompt, many models
// quickly hit context shifting and go bad.
if len(maxPrompt) > numCtx*2 { // typically yields ~1/2 full context
maxPrompt = maxPrompt[:numCtx*2]
}
testCases := []struct {
prompt string
anyResp []string
}{
{blueSkyPrompt, blueSkyExpected},
{maxPrompt, []string{"shakespeare", "oppression", "sorrows", "gutenberg", "child", "license", "sonnet", "melancholy", "love", "sorrow", "beauty"}},
}
var gpuPercent int
for _, tc := range testCases {
if len(tc.prompt) > 100 && (longContextFlake || skipLongPrompt) {
slog.Info("skipping long prompt", "model", model, "num_ctx", numCtx, "gpu_percent", gpuPercent)
continue
}
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: tc.prompt,
},
},
KeepAlive: &api.Duration{Duration: 20 * time.Second}, // long enough to ensure a ps returns
Options: map[string]interface{}{
"temperature": 0,
"seed": 123,
"num_ctx": numCtx,
},
}
atLeastOne := false
var resp api.ChatResponse
stream := false
req.Stream = &stream
// Avoid potentially getting stuck indefinitely
limit := 5 * time.Minute
genCtx, cancel := context.WithDeadlineCause(
ctx,
time.Now().Add(limit),
fmt.Errorf("generate on model %s with ctx %d took longer than %v", model, numCtx, limit),
)
defer cancel()
err = client.Chat(genCtx, &req, func(rsp api.ChatResponse) error {
resp = rsp
return nil
})
if err != nil {
// Avoid excessive test runs, but don't consider a failure with massive context
if numCtx > 16384 && strings.Contains(err.Error(), "took longer") {
slog.Warn("max context was taking too long, skipping", "error", err)
keepGoing = false
skipLongPrompt = true
continue
}
t.Fatalf("generate error: ctx:%d err:%s", numCtx, err)
}
loaded = true
for _, expResp := range tc.anyResp {
if strings.Contains(strings.ToLower(resp.Message.Content), expResp) {
atLeastOne = true
break
}
}
if !atLeastOne {
t.Fatalf("response didn't contain expected values: ctx:%d expected:%v response:%s ", numCtx, tc.anyResp, resp.Message.Content)
}
models, err := client.ListRunning(ctx)
if err != nil {
slog.Warn("failed to list running models", "error", err)
continue
}
if len(models.Models) > 1 {
slog.Warn("multiple models loaded, may impact performance results", "loaded", models.Models)
}
for _, m := range models.Models {
if m.Name == model {
if m.SizeVRAM == 0 {
slog.Info("Model fully loaded into CPU")
gpuPercent = 0
keepGoing = false
skipLongPrompt = true
} else if m.SizeVRAM == m.Size {
slog.Info("Model fully loaded into GPU")
gpuPercent = 100
} else {
sizeCPU := m.Size - m.SizeVRAM
cpuPercent := math.Round(float64(sizeCPU) / float64(m.Size) * 100)
gpuPercent = int(100 - cpuPercent)
slog.Info("Model split between CPU/GPU", "CPU", cpuPercent, "GPU", gpuPercent)
keepGoing = false
// Heuristic to avoid excessive test run time
if gpuPercent < 90 {
skipLongPrompt = true
}
}
}
}
// Round the logged prompt count for comparisons across versions/configurations which can vary slightly
fmt.Fprintf(os.Stderr, "MODEL_PERF_HEADER:%s,%s,%s,%s,%s,%s,%s\n",
"MODEL",
"CONTEXT",
"GPU PERCENT",
"APPROX PROMPT COUNT",
"LOAD TIME",
"PROMPT EVAL TPS",
"EVAL TPS",
)
fmt.Fprintf(os.Stderr, "MODEL_PERF_DATA:%s,%d,%d,%d,%0.2f,%0.2f,%0.2f\n",
model,
numCtx,
gpuPercent,
(resp.PromptEvalCount/10)*10,
float64(resp.LoadDuration)/1000000000.0,
float64(resp.PromptEvalCount)/(float64(resp.PromptEvalDuration)/1000000000.0),
float64(resp.EvalCount)/(float64(resp.EvalDuration)/1000000000.0),
)
}
}
})
}
}
+6 -2
View File
@@ -1,4 +1,4 @@
//go:build integration && models
//go:build integration && release
package integration
@@ -14,7 +14,11 @@ import (
"github.com/ollama/ollama/api"
)
func TestQuantization(t *testing.T) {
func runQuantization(t *testing.T) {
if testModel != "" {
t.Skip("exercises quantization with a fixed source model, not applicable with model override")
}
sourceModels := []string{
"qwen2.5:0.5b-instruct-fp16",
}
+48
View File
@@ -0,0 +1,48 @@
//go:build integration && fast
package integration
var (
fastNumPredictModel = "llama3.2:1b"
fastChatModels = []integrationModel{
{Name: "gemma4", MinVRAMGB: 8},
{Name: "gemma4:12b", MinVRAMGB: 16},
{Name: "qwen3.5:2b-nvfp4", MinVRAMGB: 4},
}
fastEmbedModels = []string{"qwen3-embedding"}
fastVisionTextModels = []string{"gemma4"}
fastToolsModels = []string{"qwen3.5:2b"}
fastToolsStressModels = []string{"lfm2.5"}
fastAudioModels = []string{"gemma4:e2b"}
)
func init() {
// API/basic/context/concurrency smoke cases
registerIntegrationCases(
integrationTestCase("api-generate", smol, runAPIGenerate),
integrationTestCase("api-chat", smol, runAPIChat),
integrationTestCase("api-list-models", "", runAPIListModels),
integrationTestCase("api-show-model", "llama3.2", runAPIShowModel),
integrationTestCase("generate-logprobs", smol, runAPIGenerateLogprobs),
integrationTestCase("chat-logprobs", smol, runAPIChatLogprobs),
integrationTestCase("blue-sky", smol, runBlueSky),
integrationTestCase("thinking-enabled", smol, runThinkingEnabled),
integrationTestCase("thinking-suppressed", smol, runThinkingSuppressed),
integrationModelTestCase("num-predict", fastNumPredictModel, runNumPredict),
integrationTestCase("embedding-api", "all-minilm", runAllMiniLMEmbeddings),
integrationTestCase("embed-api-truncate", "all-minilm", runAllMiniLMEmbedTruncate),
integrationTestCase("context-long-input", smol, runLongInputContext),
integrationTestCase("context-exhaustion", smol, runContextExhaustion),
integrationTestCase("generate-history", smol, runGenerateWithHistory),
integrationTestCase("concurrent-chat", smol, runConcurrentChat),
)
// Model-parametric cases
registerModelMinVRAM(fastChatModels)
registerChatCases(testModels(modelNames(fastChatModels)))
registerEmbeddingCases(testModels(fastEmbedModels))
registerVisionTextCases(testModels(fastVisionTextModels))
registerToolCases(testModels(fastToolsModels))
registerToolStressCases(testModels(fastToolsStressModels))
registerAudioTranscriptionCases(testModels(fastAudioModels))
}
+143
View File
@@ -0,0 +1,143 @@
//go:build integration && (fast || release || library)
package integration
import (
"strings"
"testing"
)
func runIntegrationGroup(t *testing.T, cases ...string) {
t.Helper()
selected := map[string]struct{}{}
for _, c := range cases {
selected[c] = struct{}{}
}
var ran bool
for _, c := range integrationCases {
if _, ok := selected[c.Case]; !ok {
continue
}
ran = true
c := c
name := c.Case
if c.Model != "" {
name += "/" + testName(c.Model)
}
t.Run(name, c.Run)
}
if !ran {
t.Skip("no integration cases selected")
}
}
func TestAPI(t *testing.T) {
runIntegrationGroup(t,
"api-generate",
"api-chat",
"api-list-models",
"api-show-model",
"generate-logprobs",
"chat-logprobs",
)
}
func TestBasic(t *testing.T) {
runIntegrationGroup(t,
"blue-sky",
"unicode-input",
"unicode-output",
"unicode-model-dir",
"num-predict",
"thinking-enabled",
"thinking-suppressed",
)
}
func TestChat(t *testing.T) {
runIntegrationGroup(t,
"chat",
"chat-history",
)
}
func TestEmbedding(t *testing.T) {
runIntegrationGroup(t,
"embed",
"embed-correlation",
"embedding-api",
"embed-api",
"embed-api-batch",
"embed-api-truncate",
"embed-truncation",
"embed-large-input",
"embed-status-code",
)
}
func TestVision(t *testing.T) {
runIntegrationGroup(t,
"vision-multiturn",
"vision-count",
"vision-scene",
"vision-spatial",
"vision-detail",
"vision-multi-image",
"vision-description",
"vision-split-batch",
"vision-text",
)
}
func TestAudio(t *testing.T) {
runIntegrationGroup(t,
"audio-transcription",
"audio-response",
"openai-audio-transcription",
"openai-chat-audio",
)
}
func TestContext(t *testing.T) {
runIntegrationGroup(t,
"context-long-input",
"context-exhaustion",
"generate-history",
"parallel-generate-history",
"parallel-chat-history",
)
}
func TestConcurrency(t *testing.T) {
runIntegrationGroup(t,
"concurrent-chat",
"scheduler-multimodel",
"scheduler-max-queue",
)
}
func TestTools(t *testing.T) {
runIntegrationGroup(t,
"tools",
"tools-stress",
)
}
func TestCreate(t *testing.T) {
runIntegrationGroup(t,
"create-safetensors",
"create-gguf",
)
}
func TestQuantization(t *testing.T) {
runIntegrationGroup(t, "quantization")
}
func TestImageGeneration(t *testing.T) {
runIntegrationGroup(t, "image-generation")
}
func testName(s string) string {
return strings.NewReplacer("/", "~", " ", "_").Replace(s)
}
+231
View File
@@ -0,0 +1,231 @@
//go:build integration && library
package integration
// Broad public library inventory, roughly newest to oldest from
// https://ollama.com/library?sort=newest. Cloud-only models are omitted;
// models with only very large local tags are kept commented in place.
var libraryModels = []string{
"lfm2.5",
"mistral-medium-3.5",
"granite4.1",
"nemotron3",
"laguna-xs.2",
"qwen3.6",
"medgemma1.5",
"medgemma",
"nemotron-cascade-2",
"gemma4",
"lfm2",
"nemotron-3-super",
"qwen3.5",
"qwen3-coder-next",
"glm-ocr",
"lfm2.5-thinking",
"glm-4.7-flash",
"translategemma",
"nemotron-3-nano",
"functiongemma",
"olmo-3.1",
"olmo-3",
"nomic-embed-text-v2-moe",
"devstral-small-2",
"rnj-1",
"devstral-2",
"qwen3-next",
"ministral-3",
"deepseek-ocr",
// "cogito-2.1", // only local tags are very large (404.0GB minimum)
"gpt-oss-safeguard",
"qwen3-vl",
"granite4",
"qwen3-embedding",
"embeddinggemma",
// "deepseek-v3.1", // only local tags are very large (404.0GB minimum)
"gpt-oss",
"qwen3-coder",
"mistral-small3.2",
"gemma3n",
"magistral",
"devstral",
"qwen2.5vl",
"phi4-reasoning",
"phi4-mini-reasoning",
"qwen3",
"granite3.3",
"deepcoder",
"mistral-small3.1",
"cogito",
"llama4",
"exaone-deep",
"command-a",
"gemma3",
"command-r7b-arabic",
"granite3.2-vision",
"phi4-mini",
"granite3.2",
"r1-1776",
"deepscaler",
"openthinker",
"deepseek-r1",
"olmo2",
"command-r7b",
// "deepseek-v3", // only local tags are very large (404.0GB minimum)
"phi4",
"dolphin3",
"smallthinker",
"granite3.1-dense",
"granite3.1-moe",
"falcon3",
"granite-embedding",
"exaone3.5",
"llama3.3",
"snowflake-arctic-embed2",
"sailor2",
"qwq",
"marco-o1",
"tulu3",
"athene-v2",
"opencoder",
"llama3.2-vision",
"smollm2",
"granite3-guardian",
"aya-expanse",
"granite3-dense",
"granite3-moe",
"nemotron",
"shieldgemma",
"llama-guard3",
"llama3.2",
"qwen2.5-coder",
"solar-pro",
"nemotron-mini",
"qwen2.5",
"bespoke-minicheck",
"mistral-small",
"reader-lm",
"minicpm-v",
// "deepseek-v2.5", // only local tags are very large (133.0GB minimum)
"reflection",
"yi-coder",
"qwen2-math",
"hermes3",
"phi3.5",
"smollm",
"bge-large",
"paraphrase-multilingual",
"bge-m3",
"mistral-large",
"llama3.1",
"nuextract",
"mistral-nemo",
"firefunction-v2",
"llama3-groq-tool-use",
"mathstral",
"codegeex4",
"glm4",
"internlm2",
"gemma2",
"deepseek-coder-v2",
"qwen2",
"deepseek-v2",
"codestral",
"granite-code",
"aya",
"falcon2",
"llama3-chatqa",
"llava-phi3",
"llava-llama3",
"llama3-gradient",
"moondream",
"phi3",
"dolphin-llama3",
"llama3",
"codeqwen",
"snowflake-arctic-embed",
"dbrx",
"command-r-plus",
"wizardlm2",
"codegemma",
"command-r",
"mxbai-embed-large",
"dolphincoder",
"starcoder2",
"all-minilm",
"nomic-embed-text",
"gemma",
"stablelm2",
"duckdb-nsql",
"qwen",
"tinydolphin",
"stable-code",
"nous-hermes2-mixtral",
"megadolphin",
"llama-pro",
"tinyllama",
"openhermes",
"notux",
"notus",
"dolphin-mistral",
"nous-hermes2",
"dolphin-phi",
"phi",
"solar",
"dolphin-mixtral",
"mixtral",
"bakllava",
"llava",
"stablelm-zephyr",
"magicoder",
"deepseek-llm",
"meditron",
"starling-lm",
"orca2",
"deepseek-coder",
"alfred",
"goliath",
"neural-chat",
"openchat",
"yi",
"yarn-mistral",
"yarn-llama2",
"xwinlm",
"mistrallite",
"codebooga",
"mistral-openorca",
"zephyr",
"nexusraven",
"samantha-mistral",
"starcoder",
"sqlcoder",
"mistral",
"falcon",
"wizardcoder",
"phind-codellama",
"codellama",
"wizardlm",
"wizardlm-uncensored",
"wizard-vicuna",
"wizard-vicuna-uncensored",
"wizard-math",
"vicuna",
"stable-beluga",
"orca-mini",
"open-orca-platypus2",
"nous-hermes",
"medllama2",
"llama2",
"llama2-uncensored",
"llama2-chinese",
"everythinglm",
"codeup",
}
func init() {
// Broad model sweeps. Each case skips models that do not expose the
// capability it is testing.
registerChatCases(testModels(libraryModels))
registerLibraryEmbeddingCases(testModels(libraryModels))
registerToolCases(testModels(libraryModels))
registerVisionTextCases(testModels(libraryModels))
}
+132
View File
@@ -0,0 +1,132 @@
//go:build integration && release
package integration
var (
releaseUnicodeInputModel = integrationModel{Name: "deepseek-coder-v2:16b-lite-instruct-q2_K", MinVRAMGB: 12}
releaseUnicodeOutputModel = "gemma2:2b"
releaseNumPredictModel = "llama3.2:1b"
releaseParallelHistoryModel = integrationModel{Name: "gpt-oss:20b", MinVRAMGB: 16}
releaseChatModels = []integrationModel{
{Name: "gemma4", MinVRAMGB: 8},
{Name: "gemma4:12b", MinVRAMGB: 16},
{Name: "lfm2.5", MinVRAMGB: 6},
{Name: "granite4.1:8b", MinVRAMGB: 6},
{Name: "gpt-oss:20b", MinVRAMGB: 16},
{Name: "qwen3.6:27b", MinVRAMGB: 20},
{Name: "qwen3.5:2b", MinVRAMGB: 4},
{Name: "qwen3.5:2b-nvfp4", MinVRAMGB: 4},
{Name: "deepseek-r1:8b", MinVRAMGB: 6},
{Name: "mistral-small3.2:latest", MinVRAMGB: 16},
{Name: "llama3.2:latest"},
{Name: "gemma4:e2b-nvfp4", MinVRAMGB: 8},
}
releaseEmbedModels = []string{
"embeddinggemma",
"nomic-embed-text",
"all-minilm",
"bge-large",
"bge-m3",
"granite-embedding",
"mxbai-embed-large",
"paraphrase-multilingual",
"snowflake-arctic-embed",
"snowflake-arctic-embed2",
"qwen3-embedding",
}
releaseVisionModels = []string{
"nemotron3:33b",
"gemma4",
"qwen3.6:27b",
// "llama3.2-vision", // TODO: re-enable when llama.cpp supports mllama.
}
releaseVisionTextModels = []string{
"gemma4",
"qwen3.6:27b",
"qwen3.5:2b",
// "llama3.2-vision", // TODO: re-enable when llama.cpp supports mllama.
"ministral-3:3b",
}
releaseToolsModels = []string{
"lfm2.5",
"nemotron3:33b",
"gemma4",
"gpt-oss:20b",
"qwen3.6:27b",
}
releaseAudioModels = []string{
"nemotron3:33b",
"gemma4:e2b",
"gemma4:e4b",
}
)
const releaseSplitBatchVisionModel = "qwen3.5:2b"
func init() {
// Fixed release regression cases
registerIntegrationCases(
integrationTestCase("api-generate", smol, runAPIGenerate),
integrationTestCase("api-chat", smol, runAPIChat),
integrationTestCase("api-list-models", "", runAPIListModels),
integrationTestCase("api-show-model", "llama3.2", runAPIShowModel),
integrationTestCase("generate-logprobs", smol, runAPIGenerateLogprobs),
integrationTestCase("chat-logprobs", smol, runAPIChatLogprobs),
integrationTestCase("blue-sky", smol, runBlueSky),
integrationModelTestCase("unicode-input", releaseUnicodeInputModel.Name, runUnicode),
integrationModelTestCase("unicode-output", releaseUnicodeOutputModel, runExtendedUnicodeOutput),
integrationTestCase("unicode-model-dir", smol, runUnicodeModelDir),
integrationModelTestCase("num-predict", releaseNumPredictModel, runNumPredict),
integrationModelsTestCase("embed-correlation", releaseEmbedModels, runEmbedCosineDistanceCorrelation),
integrationTestCase("embedding-api", "all-minilm", runAllMiniLMEmbeddings),
integrationTestCase("embed-api", "all-minilm", runAllMiniLMEmbed),
integrationTestCase("embed-api-batch", "all-minilm", runAllMiniLMBatchEmbed),
integrationTestCase("embed-api-truncate", "all-minilm", runAllMiniLMEmbedTruncate),
integrationModelsTestCase("embed-truncation", releaseEmbedModels, runEmbedTruncation),
integrationModelsTestCase("embed-large-input", releaseEmbedModels, runEmbedLargeInput),
integrationModelsTestCase("embed-status-code", releaseEmbedModels, runEmbedStatusCode),
integrationModelsTestCase("vision-multiturn", releaseVisionModels, runVisionMultiTurn),
integrationModelsTestCase("vision-count", releaseVisionModels, runVisionObjectCounting),
integrationModelsTestCase("vision-scene", releaseVisionModels, runVisionSceneUnderstanding),
integrationModelsTestCase("vision-spatial", releaseVisionModels, runVisionSpatialReasoning),
integrationModelsTestCase("vision-detail", releaseVisionModels, runVisionDetailRecognition),
integrationModelsTestCase("vision-multi-image", releaseVisionModels, runVisionMultiImage),
integrationModelsTestCase("vision-description", releaseVisionModels, runVisionImageDescription),
integrationModelTestCase("vision-split-batch", releaseSplitBatchVisionModel, runIntegrationSplitBatch),
integrationModelsTestCase("audio-response", releaseAudioModels, runAudioResponse),
integrationModelsTestCase("openai-audio-transcription", releaseAudioModels, runOpenAIAudioTranscription),
integrationModelsTestCase("openai-chat-audio", releaseAudioModels, runOpenAIChatWithAudio),
integrationTestCase("context-long-input", smol, runLongInputContext),
integrationTestCase("context-exhaustion", smol, runContextExhaustion),
integrationModelTestCase("parallel-generate-history", releaseParallelHistoryModel.Name, runParallelGenerateWithHistory),
integrationTestCase("generate-history", smol, runGenerateWithHistory),
integrationModelTestCase("parallel-chat-history", defaultTestModel(releaseParallelHistoryModel.Name), runParallelChatWithHistory),
integrationTestCase("chat-history", smol, runChatWithHistory),
integrationTestCase("concurrent-chat", smol, runConcurrentChat),
integrationTestCase("scheduler-multimodel", "", runMultiModelStress),
integrationTestCase("scheduler-max-queue", smol, runMaxQueue),
integrationTestCase("thinking-enabled", smol, runThinkingEnabled),
integrationTestCase("thinking-suppressed", smol, runThinkingSuppressed),
integrationTestCase("create-safetensors", "", runCreateSafetensorsLLM),
integrationTestCase("create-gguf", "", runCreateGGUF),
integrationTestCase("quantization", "qwen2.5:0.5b-instruct-fp16", runQuantization),
integrationTestCase("image-generation", "", runImageGeneration),
)
// Model-parametric cases
registerModelMinVRAM([]integrationModel{releaseUnicodeInputModel, releaseParallelHistoryModel})
registerModelMinVRAM(releaseChatModels)
registerChatCases(testModels(modelNames(releaseChatModels)))
registerEmbeddingCases(testModels(releaseEmbedModels))
registerVisionTextCases(testModels(releaseVisionTextModels))
registerToolCases(testModels(releaseToolsModels))
registerToolStressCases(testModels(releaseToolsModels))
registerAudioTranscriptionCases(testModels(releaseAudioModels))
}
+9
View File
@@ -0,0 +1,9 @@
//go:build integration && !fast && !release && !library && !imagegen
package integration
import "testing"
func TestIntegrationRequiresScope(t *testing.T) {
t.Fatal("integration tests require one of the fast, release, or library tags")
}
+140
View File
@@ -0,0 +1,140 @@
//go:build integration
package integration
import "testing"
type integrationCase struct {
Key string
Case string
Model string
Run func(t *testing.T)
}
type integrationModel struct {
Name string
MinVRAMGB uint64
}
var (
integrationCases []integrationCase
integrationCaseKeys = map[string]struct{}{}
modelMinVRAMGB = map[string]uint64{}
)
func registerIntegrationCases(cases ...integrationCase) {
for _, c := range cases {
if _, ok := integrationCaseKeys[c.Key]; ok {
continue
}
integrationCaseKeys[c.Key] = struct{}{}
integrationCases = append(integrationCases, c)
}
}
func integrationTestCase(name, model string, run func(t *testing.T)) integrationCase {
key := name
if model != "" {
key += "/" + model
}
return integrationCase{
Key: key,
Case: name,
Model: model,
Run: run,
}
}
func integrationModelTestCase(name, model string, run func(*testing.T, string)) integrationCase {
return integrationTestCase(name, model, func(t *testing.T) {
run(t, model)
})
}
func integrationModelsTestCase(name string, models []string, run func(*testing.T, []string)) integrationCase {
return integrationTestCase(name, "", func(t *testing.T) {
run(t, models)
})
}
func registerModelIntegrationCases(name string, models []string, run func(*testing.T, string)) {
cases := make([]integrationCase, 0, len(models))
for _, model := range models {
model := model
cases = append(cases, integrationCase{
Key: name + "/" + model,
Case: name,
Model: model,
Run: func(t *testing.T) {
run(t, model)
},
})
}
registerIntegrationCases(cases...)
}
func modelNames(models []integrationModel) []string {
names := make([]string, 0, len(models))
for _, model := range models {
names = append(names, model.Name)
}
return names
}
func registerModelMinVRAM(models []integrationModel) {
for _, model := range models {
if model.MinVRAMGB > 0 {
modelMinVRAMGB[model.Name] = model.MinVRAMGB
}
}
}
func skipRegisteredMinVRAM(t *testing.T, model string) {
t.Helper()
if v, ok := modelMinVRAMGB[model]; ok {
skipUnderMinVRAM(t, v)
}
}
type knownIntegrationFlake struct {
Scenario string
Model string
Reason string
}
var knownIntegrationFlakes = []knownIntegrationFlake{
{
Scenario: "tools-stress/multi_turn",
Model: "gemma4",
Reason: "returns an empty response on the agent-style multi-turn tool prompt",
},
{
Scenario: "tools-stress/multi_turn",
Model: "qwen3.5:2b",
Reason: "returns an empty response after the tool result in the agent-style multi-turn prompt",
},
{
Scenario: "vision-text",
Model: "qwen3.5:2b",
Reason: "times out instead of returning OCR text for the Ollamas image",
},
{
Scenario: "vision-multiturn",
Model: "gemma4",
Reason: "counts five animals in the Ollamas image instead of four",
},
{
Scenario: "vision-count",
Model: "gemma4",
Reason: "counts five animals in the docs image instead of four",
},
}
func skipKnownIntegrationFlake(t *testing.T, scenario, model string) {
t.Helper()
for _, flake := range knownIntegrationFlakes {
if flake.Scenario == scenario && flake.Model == model {
t.Skipf("known model/scenario flake: %s", flake.Reason)
}
}
}
+4 -4
View File
@@ -11,9 +11,9 @@ import (
"github.com/ollama/ollama/api"
)
// TestThinkingEnabled verifies that when thinking is requested, the model
// runThinkingEnabled verifies that when thinking is requested, the model
// produces both thinking and content output without leaking raw channel tags.
func TestThinkingEnabled(t *testing.T) {
func runThinkingEnabled(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
@@ -84,9 +84,9 @@ func TestThinkingEnabled(t *testing.T) {
}
}
// TestThinkingSuppressed verifies that when thinking is NOT requested,
// runThinkingSuppressed verifies that when thinking is NOT requested,
// the model does not leak thinking/channel content into the response.
func TestThinkingSuppressed(t *testing.T) {
func runThinkingSuppressed(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
+67 -94
View File
@@ -15,117 +15,90 @@ import (
"github.com/ollama/ollama/api"
)
// TestAPIToolCallingStress tests tool calling with complex, agent-style prompts
// that include large system messages, multiple tools, and multi-turn conversations.
// This catches cache corruption and parser bugs that simple tool tests miss.
func TestAPIToolCallingStress(t *testing.T) {
func registerToolStressCases(models []string) {
registerModelIntegrationCases("tools-stress", models, runAPIToolCallingStressModel)
}
var toolStressSkipModels = map[string]string{
"lfm2.5-thinking": "returns text instead of tool calls with complex system prompts",
"qwen3.5:2b": "2B model too small for reliable multi-tool agent prompts",
"qwen3-vl": "vision model, extremely slow with complex tool prompts",
"llama3.2": "3B model too small for reliable multi-tool agent prompts",
"mistral": "7B v0.3 returns text instead of tool calls with complex prompts",
"mixtral:8x22b": "returns text instead of tool calls with complex prompts",
"qwen2": "returns text instead of tool calls with complex prompts",
"granite3.3": "returns text instead of tool calls with complex prompts",
}
func runAPIToolCallingStressModel(t *testing.T, model string) {
initialTimeout := 120 * time.Second
streamTimeout := 120 * time.Second
softTimeout, _ := getTimeouts(t)
if time.Since(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
minVRAM := map[string]uint64{
"qwen3-vl": 16,
"gpt-oss:20b": 16,
"gpt-oss:120b": 70,
"qwen3": 6,
"llama3.1": 8,
"llama3.2": 4,
"mistral": 6,
"qwen2.5": 6,
"qwen2": 6,
"ministral-3": 20,
"mistral-nemo": 9,
"mistral-small": 16,
"mixtral:8x22b": 80,
"qwq": 20,
"granite3.3": 7,
runAPIToolCallingStressModelWithClient(t, ctx, client, model, initialTimeout, streamTimeout, toolsMinVRAM, toolStressSkipModels)
}
func runAPIToolCallingStressModelWithClient(t *testing.T, ctx context.Context, client *api.Client, model string, initialTimeout, streamTimeout time.Duration, minVRAM map[string]uint64, skipModels map[string]string) {
t.Helper()
// Skip known-bad models unless explicitly requested via env var
if reason, ok := skipModels[model]; ok && testModel == "" {
t.Skipf("skipping: %s", reason)
}
// Models that don't reliably produce tool calls with complex/multi-tool prompts.
// The stress test uses a large system prompt with many tools, simulating coding agents.
// Some models are too small, too slow, or not designed for this use case.
skipModels := map[string]string{
"lfm2.5-thinking": "returns text instead of tool calls with complex system prompts",
"qwen3-vl": "vision model, extremely slow with complex tool prompts",
"llama3.2": "3B model too small for reliable multi-tool agent prompts",
"mistral": "7B v0.3 returns text instead of tool calls with complex prompts",
"mixtral:8x22b": "returns text instead of tool calls with complex prompts",
"qwen2": "returns text instead of tool calls with complex prompts",
"granite3.3": "returns text instead of tool calls with complex prompts",
if v, ok := minVRAM[model]; ok {
skipUnderMinVRAM(t, v)
}
requireCapability(ctx, t, client, model, "tools")
models := testModels(libraryToolsModels)
// Preload and skip if not sufficiently GPU-loaded to avoid timeouts
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model})
skipIfNotGPULoaded(ctx, t, client, model, 80)
softTimeout, _ := getTimeouts(t)
tools := stressTestTools()
for _, model := range models {
t.Run(model, func(t *testing.T) {
if time.Since(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
return
}
// Skip known-bad models unless explicitly requested via env var
if reason, ok := skipModels[model]; ok && testModel == "" {
t.Skipf("skipping: %s", reason)
}
if testModel != "" {
requireCapability(ctx, t, client, model, "tools")
}
if v, ok := minVRAM[model]; ok {
skipUnderMinVRAM(t, v)
}
// Large system prompt that mimics real coding agents (opencode, Claude Code, etc.)
// This is intentionally very long (~5000+ tokens) to match the prompt sizes that
// real coding agents send. The combination of a large system prompt, many tools,
// and thinking mode is what triggers failures in some models.
systemPrompt := stressTestSystemPrompt()
pullOrSkip(ctx, t, client, model)
// Test 1: First request (fresh prompt processing)
// Use a direct prompt that tells the model exactly what tool to use,
// reducing the chance it asks for clarification instead.
t.Run("first_request", func(t *testing.T) {
testToolCall(t, ctx, client, model, systemPrompt, tools,
"Run git diff main to review the code changes on the current branch.",
initialTimeout, streamTimeout)
})
// Preload and skip if not sufficiently GPU-loaded to avoid timeouts
err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", model, err)
}
skipIfNotGPULoaded(ctx, t, client, model, 80)
// Test 2: Repeat with same prompt (tests cache reuse)
t.Run("cached_request", func(t *testing.T) {
testToolCall(t, ctx, client, model, systemPrompt, tools,
"Run git diff main to review the code changes on the current branch.",
initialTimeout, streamTimeout)
})
tools := stressTestTools()
// Test 3: Different user message (partial cache hit)
t.Run("different_user_message", func(t *testing.T) {
testToolCall(t, ctx, client, model, systemPrompt, tools,
"Read the file at ./go.mod and tell me what dependencies we have.",
initialTimeout, streamTimeout)
})
// Large system prompt that mimics real coding agents (opencode, Claude Code, etc.)
// This is intentionally very long (~5000+ tokens) to match the prompt sizes that
// real coding agents send. The combination of a large system prompt, many tools,
// and thinking mode is what triggers failures in some models.
systemPrompt := stressTestSystemPrompt()
// Test 1: First request (fresh prompt processing)
// Use a direct prompt that tells the model exactly what tool to use,
// reducing the chance it asks for clarification instead.
t.Run("first_request", func(t *testing.T) {
testToolCall(t, ctx, client, model, systemPrompt, tools,
"Run git diff main to review the code changes on the current branch.",
initialTimeout, streamTimeout)
})
// Test 2: Repeat with same prompt (tests cache reuse)
t.Run("cached_request", func(t *testing.T) {
testToolCall(t, ctx, client, model, systemPrompt, tools,
"Run git diff main to review the code changes on the current branch.",
initialTimeout, streamTimeout)
})
// Test 3: Different user message (partial cache hit)
t.Run("different_user_message", func(t *testing.T) {
testToolCall(t, ctx, client, model, systemPrompt, tools,
"Read the file at ./go.mod and tell me what dependencies we have.",
initialTimeout, streamTimeout)
})
// Test 4: Multi-turn with tool response
t.Run("multi_turn", func(t *testing.T) {
testToolCallMultiTurn(t, ctx, client, model, systemPrompt, tools,
initialTimeout, streamTimeout)
})
})
}
// Test 4: Multi-turn with tool response
t.Run("multi_turn", func(t *testing.T) {
skipKnownIntegrationFlake(t, "tools-stress/multi_turn", model)
testToolCallMultiTurn(t, ctx, client, model, systemPrompt, tools,
initialTimeout, streamTimeout)
})
}
func newTool(name, description string, required []string, props map[string]api.ToolProperty) api.Tool {
+115 -111
View File
@@ -20,134 +20,138 @@ func testPropsMap(m map[string]api.ToolProperty) *api.ToolPropertiesMap {
return props
}
func TestAPIToolCalling(t *testing.T) {
func registerToolCases(models []string) {
registerModelIntegrationCases("tools", models, runAPIToolCallingModel)
}
var toolsMinVRAM = map[string]uint64{
"gemma4": 8,
"lfm2.5": 6,
"granite4.1:3b": 4,
"granite4.1:8b": 6,
"nemotron3:33b": 32,
"qwen3.5:2b": 4,
"qwen3.6:27b": 20,
"qwen3-vl": 16,
"gpt-oss:20b": 16,
"gpt-oss:120b": 70,
"qwen3": 6,
"llama3.1": 8,
"llama3.2": 4,
"mistral": 6,
"qwen2.5": 6,
"qwen2": 6,
"ministral-3": 20,
"mistral-nemo": 9,
"mistral-small": 16,
"mixtral:8x22b": 80,
"qwq": 20,
"granite3.3": 7,
}
func runAPIToolCallingModel(t *testing.T, model string) {
initialTimeout := 60 * time.Second
streamTimeout := 60 * time.Second
softTimeout, hardTimeout := getTimeouts(t)
if time.Since(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
}
ctx, cancel := context.WithTimeout(context.Background(), hardTimeout)
defer cancel()
client, _, cleanup := InitServerConnection(ctx, t)
defer cleanup()
minVRAM := map[string]uint64{
"gemma4": 8,
"qwen3-vl": 16,
"gpt-oss:20b": 16,
"gpt-oss:120b": 70,
"qwen3": 6,
"llama3.1": 8,
"llama3.2": 4,
"mistral": 6,
"qwen2.5": 6,
"qwen2": 6,
"ministral-3": 20,
"mistral-nemo": 9,
"mistral-small": 16,
"mixtral:8x22b": 80,
"qwq": 20,
"granite3.3": 7,
runAPIToolCallingModelWithClient(t, ctx, client, model, initialTimeout, streamTimeout, toolsMinVRAM)
}
func runAPIToolCallingModelWithClient(t *testing.T, ctx context.Context, client *api.Client, model string, initialTimeout, streamTimeout time.Duration, minVRAM map[string]uint64) {
t.Helper()
if v, ok := minVRAM[model]; ok {
skipUnderMinVRAM(t, v)
}
requireCapability(ctx, t, client, model, "tools")
tools := []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get the current weather for a location",
Parameters: api.ToolFunctionParameters{
Type: "object",
Required: []string{"location"},
Properties: testPropsMap(map[string]api.ToolProperty{
"location": {
Type: api.PropertyType{"string"},
Description: "The city and state, e.g. San Francisco, CA",
},
}),
},
},
},
}
models := testModels(libraryToolsModels)
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: "Call get_weather with location set to San Francisco.",
},
},
Tools: tools,
Options: map[string]any{
"temperature": 0,
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
}
for _, model := range models {
t.Run(model, func(t *testing.T) {
if time.Now().Sub(started) > softTimeout {
t.Skip("skipping remaining tests to avoid excessive runtime")
return
}
stallTimer := time.NewTimer(initialTimeout)
var gotToolCall bool
var lastToolCall api.ToolCall
if testModel != "" {
requireCapability(ctx, t, client, model, "tools")
}
if v, ok := minVRAM[model]; ok {
skipUnderMinVRAM(t, v)
}
fn := func(response api.ChatResponse) error {
if len(response.Message.ToolCalls) > 0 {
gotToolCall = true
lastToolCall = response.Message.ToolCalls[len(response.Message.ToolCalls)-1]
}
if !stallTimer.Reset(streamTimeout) {
return fmt.Errorf("stall was detected while streaming response, aborting")
}
return nil
}
pullOrSkip(ctx, t, client, model)
stream := true
req.Stream = &stream
done := make(chan int)
var genErr error
go func() {
genErr = client.Chat(ctx, &req, fn)
done <- 0
}()
tools := []api.Tool{
{
Type: "function",
Function: api.ToolFunction{
Name: "get_weather",
Description: "Get the current weather in a given location",
Parameters: api.ToolFunctionParameters{
Type: "object",
Required: []string{"location"},
Properties: testPropsMap(map[string]api.ToolProperty{
"location": {
Type: api.PropertyType{"string"},
Description: "The city and state, e.g. San Francisco, CA",
},
}),
},
},
},
}
select {
case <-stallTimer.C:
t.Errorf("tool-calling chat never started. Timed out after: %s", initialTimeout.String())
case <-done:
if genErr != nil {
t.Fatalf("chat failed: %v", genErr)
}
req := api.ChatRequest{
Model: model,
Messages: []api.Message{
{
Role: "user",
Content: "Call get_weather with location set to San Francisco.",
},
},
Tools: tools,
Options: map[string]any{
"temperature": 0,
},
KeepAlive: &api.Duration{Duration: 10 * time.Second},
}
if !gotToolCall {
t.Fatalf("expected at least one tool call, got none")
}
stallTimer := time.NewTimer(initialTimeout)
var gotToolCall bool
var lastToolCall api.ToolCall
if lastToolCall.Function.Name != "get_weather" {
t.Errorf("unexpected tool called: got %q want %q", lastToolCall.Function.Name, "get_weather")
}
fn := func(response api.ChatResponse) error {
if len(response.Message.ToolCalls) > 0 {
gotToolCall = true
lastToolCall = response.Message.ToolCalls[len(response.Message.ToolCalls)-1]
}
if !stallTimer.Reset(streamTimeout) {
return fmt.Errorf("stall was detected while streaming response, aborting")
}
return nil
}
stream := true
req.Stream = &stream
done := make(chan int)
var genErr error
go func() {
genErr = client.Chat(ctx, &req, fn)
done <- 0
}()
select {
case <-stallTimer.C:
t.Errorf("tool-calling chat never started. Timed out after: %s", initialTimeout.String())
case <-done:
if genErr != nil {
t.Fatalf("chat failed: %v", genErr)
}
if !gotToolCall {
t.Fatalf("expected at least one tool call, got none")
}
if lastToolCall.Function.Name != "get_weather" {
t.Errorf("unexpected tool called: got %q want %q", lastToolCall.Function.Name, "get_weather")
}
if _, ok := lastToolCall.Function.Arguments.Get("location"); !ok {
t.Errorf("expected tool arguments to include 'location', got: %s", lastToolCall.Function.Arguments.String())
}
case <-ctx.Done():
t.Error("outer test context done while waiting for tool-calling chat")
}
})
if _, ok := lastToolCall.Function.Arguments.Get("location"); !ok {
t.Errorf("expected tool arguments to include 'location', got: %s", lastToolCall.Function.Arguments.String())
}
case <-ctx.Done():
t.Error("outer test context done while waiting for tool-calling chat")
}
}
+84 -281
View File
@@ -31,270 +31,18 @@ import (
)
var (
smol = "llama3.2:1b"
stream = false
// testModel is set via OLLAMA_TEST_MODEL env var. When set, all tests
// that loop over model lists will test only this model, and smol is
// also overridden to use it.
testModel string
testModel = os.Getenv("OLLAMA_TEST_MODEL")
smol = defaultTestModel("llama3.2:1b")
stream = false
)
var (
started = time.Now()
// Note: add newer models at the top of the list to test them first
ollamaEngineChatModels = []string{
"nemotron3:33b",
// "laguna-xs.2:q4_K_M", // TODO: re-enable when llama.cpp supports laguna.
"gemma4",
"lfm2.5-thinking",
"ministral-3",
"qwen3-coder:30b",
"gpt-oss:20b",
"gemma3n:e2b",
"mistral-small3.2:latest",
"deepseek-r1:1.5b",
// "llama3.2-vision:latest", // TODO: re-enable when llama.cpp supports mllama.
"qwen2.5-coder:latest",
"qwen2.5vl:3b",
"qwen3:0.6b", // dense
"qwen3:1.7b", // dense
"qwen3:30b", // MOE
"gemma3:1b",
"llama3.1:latest",
"llama3.2:latest",
"gemma2:latest",
"minicpm-v:latest", // arch=qwen2
"granite-code:latest", // arch=llama
}
// MLX-backed safetensors tags. These exercise the mlxrunner subprocess
// on platforms where MLX is available (today: macOS; Linux/Windows CUDA
// coming). On other platforms, skipIfMLXUnsupported turns the load
// failure into a test skip.
mlxEngineChatModels = []string{
"laguna-xs.2:nvfp4",
"qwen3.5:2b-nvfp4", // ~2.5GB, Qwen3_5 arch
"gemma4:e2b-nvfp4", // ~7.1GB, Gemma4 arch (skipped under low VRAM)
}
llamaRunnerChatModels = []string{
"mistral:latest",
"falcon3:latest",
"granite3-moe:latest",
"command-r:latest",
"nemotron-mini:latest",
"phi3.5:latest",
"internlm2:latest",
"codellama:latest", // arch=llama
"phi3:latest",
}
// Some library models are quite large - ensure large VRAM and sufficient disk space
// before running scenarios based on this set
libraryChatModels = []string{
"alfred",
"athene-v2",
"aya-expanse",
"aya",
"bakllava",
"bespoke-minicheck",
"codebooga",
"codegeex4",
"codegemma",
"codellama",
"codeqwen",
"codestral",
"codeup",
"cogito",
"command-a",
"command-r-plus",
"command-r",
"command-r7b-arabic",
"command-r7b",
"dbrx",
"deepcoder",
"deepscaler",
"deepseek-coder-v2",
"deepseek-coder",
"deepseek-llm",
"deepseek-r1",
// "deepseek-v2.5", // requires 155 GB VRAM
"deepseek-v2",
// "deepseek-v3", // requires 482 GB VRAM
"devstral",
"dolphin-llama3",
"dolphin-mistral",
"dolphin-mixtral",
"dolphin-phi",
"dolphin3",
"dolphincoder",
"duckdb-nsql",
"everythinglm",
"exaone-deep",
"exaone3.5",
"falcon",
"falcon2",
"falcon3",
"firefunction-v2",
"gemma",
"gemma2",
"gemma3",
"gemma3n",
"gemma4",
"glm4",
"goliath",
"gpt-oss:20b",
"granite-code",
"granite3-dense",
"granite3-guardian",
"granite3-moe",
"granite3.1-dense",
"granite3.1-moe",
"granite3.2-vision",
"granite3.2",
"granite3.3",
"hermes3",
"internlm2",
"lfm2.5-thinking",
"llama-guard3",
"llama-pro",
"llama2-chinese",
"llama2-uncensored",
"llama2",
"llama3-chatqa",
"llama3-gradient",
"llama3-groq-tool-use",
"llama3.1",
// "llama3.2-vision", // TODO: re-enable when llama.cpp supports mllama.
"llama3.2",
"llama3.3",
"llama3",
"llama4",
"llava-llama3",
"llava-phi3",
"llava",
"magicoder",
"magistral",
"marco-o1",
"mathstral",
"meditron",
"medllama2",
"megadolphin",
"minicpm-v",
"ministral-3",
"mistral-large",
"mistral-nemo",
"mistral-openorca",
"mistral-small",
"mistral-small3.1",
"mistral-small3.2",
"mistral",
"mistrallite",
"mixtral",
"moondream",
"nemotron-mini",
"nemotron",
"neural-chat",
"nexusraven",
"notus",
"nous-hermes",
"nous-hermes2-mixtral",
"nous-hermes2",
"nuextract",
"olmo2",
"open-orca-platypus2",
"openchat",
"opencoder",
"openhermes",
"openthinker",
"orca-mini",
"orca2",
// "phi", // unreliable
"phi3.5",
"phi3",
"phi4-mini-reasoning",
"phi4-mini",
"phi4-reasoning",
"phi4",
"phind-codellama",
"qwen",
"qwen2-math",
"qwen2.5-coder",
"qwen2.5",
"qwen2.5vl",
"qwen2",
"qwen3:0.6b", // dense
"qwen3:30b", // MOE
"qwq",
"r1-1776",
"reader-lm",
"reflection",
"sailor2",
"samantha-mistral",
"shieldgemma",
"smallthinker",
"smollm",
"smollm2",
"solar",
"sqlcoder",
"stable-beluga",
"stable-code",
"stablelm-zephyr",
"stablelm2",
"starcoder",
"starcoder2",
"starling-lm",
"tinydolphin",
"tinyllama",
"tulu3",
"vicuna",
"wizard-math",
"wizard-vicuna-uncensored",
"wizard-vicuna",
"wizardcoder",
"wizardlm-uncensored",
"wizardlm2",
"xwinlm",
"yarn-llama2",
"yarn-mistral",
"yi-coder",
"yi",
"zephyr",
}
libraryEmbedModels = []string{
"embeddinggemma",
"nomic-embed-text",
"all-minilm",
"bge-large",
"bge-m3",
"granite-embedding",
"mxbai-embed-large",
"paraphrase-multilingual",
"snowflake-arctic-embed",
"snowflake-arctic-embed2",
"qwen3-embedding",
}
libraryToolsModels = []string{
"nemotron3:33b",
// "laguna-xs.2", // TODO: re-enable when llama.cpp supports laguna.
"gemma4",
"lfm2.5-thinking",
"qwen3-vl",
"gpt-oss:20b",
"gpt-oss:120b",
"qwen3",
"llama3.1",
"llama3.2",
"mistral",
"qwen2.5",
"ministral-3",
"mistral-nemo",
"mistral-small",
"mixtral:8x22b",
"qwq",
"granite3.3",
}
blueSkyPrompt = "why is the sky blue? Be brief but factual in your reply"
blueSkyExpected = []string{"rayleigh", "scatter", "atmosphere", "nitrogen", "oxygen", "wavelength", "interact"}
@@ -314,13 +62,18 @@ func init() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
slog.SetDefault(logger)
testModel = os.Getenv("OLLAMA_TEST_MODEL")
if testModel != "" {
slog.Info("test model override", "model", testModel)
smol = testModel
}
}
func defaultTestModel(model string) string {
if testModel != "" {
return testModel
}
return model
}
// testModels returns the override model as a single-element slice when
// OLLAMA_TEST_MODEL is set, otherwise returns the provided default list.
func testModels(defaults []string) []string {
@@ -500,7 +253,7 @@ func PullIfMissing(ctx context.Context, client *api.Client, modelName string) er
}
slog.Info("model missing", "model", modelName)
stallDuration := 60 * time.Second // This includes checksum verification, which can take a while on larger models, and slower systems
stallDuration := 2 * time.Minute // Includes checksum verification, which can take a while on larger models and slower systems.
stallTimer := time.NewTimer(stallDuration)
fn := func(resp api.ProgressResponse) error {
// fmt.Print(".")
@@ -632,14 +385,7 @@ func DoGenerate(ctx context.Context, t *testing.T, client *api.Client, genReq ap
verify := func() {
// Verify the response contains the expected data
response = buf.String()
atLeastOne := false
for _, resp := range anyResp {
if strings.Contains(strings.ToLower(response), resp) {
atLeastOne = true
break
}
}
if !atLeastOne {
if !containsExpectedResponse(response, anyResp) {
t.Fatalf("%s: none of %v found in %s", genReq.Model, anyResp, response)
}
}
@@ -777,14 +523,7 @@ func DoChat(ctx context.Context, t *testing.T, client *api.Client, req api.ChatR
verify := func() {
// Verify the response contains the expected data
response = buf.String()
atLeastOne := false
for _, resp := range anyResp {
if strings.Contains(strings.ToLower(response), resp) {
atLeastOne = true
break
}
}
if !atLeastOne {
if !containsExpectedResponse(response, anyResp) {
t.Fatalf("%s: none of %v found in \"%s\" -- request was:%s", req.Model, anyResp, response, summarizeMessages(req.Messages))
}
}
@@ -816,6 +555,24 @@ func DoChat(ctx context.Context, t *testing.T, client *api.Client, req api.ChatR
return &api.Message{Role: role, Content: buf.String()}
}
func containsExpectedResponse(response string, anyResp []string) bool {
lowerResponse := strings.ToLower(response)
normalizedResponse := normalizeResponseText(response)
for _, resp := range anyResp {
if strings.Contains(lowerResponse, strings.ToLower(resp)) {
return true
}
if strings.Contains(normalizedResponse, normalizeResponseText(resp)) {
return true
}
}
return false
}
func normalizeResponseText(s string) string {
return strings.Join(strings.Fields(strings.ToLower(s)), " ")
}
func ChatRequests() ([]api.ChatRequest, [][]string) {
genReqs, results := GenerateRequests()
reqs := make([]api.ChatRequest, len(genReqs))
@@ -835,6 +592,16 @@ func ChatRequests() ([]api.ChatRequest, [][]string) {
return reqs, results
}
func preloadGenerateModel(ctx context.Context, t *testing.T, client *api.Client, req api.GenerateRequest) {
t.Helper()
slog.Info("loading", "model", req.Model)
err := client.Generate(ctx, &req, func(response api.GenerateResponse) error { return nil })
if err != nil {
skipIfMLXUnsupported(t, err)
t.Fatalf("failed to load model %s: %s", req.Model, err)
}
}
// skipIfMLXUnsupported converts an MLX runner startup error into a test skip
// when the fingerprint matches "the MLX stack is not wired up on this host",
// and only on platforms where MLX is not yet expected to work. On Apple
@@ -851,7 +618,8 @@ func skipIfMLXUnsupported(t *testing.T, err error) {
if err == nil {
return
}
if runtime.GOOS == "darwin" && runtime.GOARCH == "arm64" {
targetGOOS, targetGOARCH := targetPlatform()
if targetGOOS == "darwin" && targetGOARCH == "arm64" {
return
}
msg := err.Error()
@@ -863,17 +631,52 @@ func skipIfMLXUnsupported(t *testing.T, err error) {
"image generation is not supported on",
} {
if strings.Contains(msg, s) {
t.Skipf("MLX not available on %s/%s: %v", runtime.GOOS, runtime.GOARCH, err)
t.Skipf("MLX not available on target %s/%s (runner %s/%s): %v", targetGOOS, targetGOARCH, runtime.GOOS, runtime.GOARCH, err)
}
}
}
func targetPlatform() (goos, goarch string) {
goos = normalizeTargetGOOS(os.Getenv("OLLAMA_TEST_HOST_OS"))
goarch = normalizeTargetGOARCH(os.Getenv("OLLAMA_TEST_HOST_ARCH"))
if goos == "" {
goos = runtime.GOOS
}
if goarch == "" {
goarch = runtime.GOARCH
}
return goos, goarch
}
func normalizeTargetGOOS(goos string) string {
switch strings.ToLower(goos) {
case "darwin":
return "darwin"
case "linux":
return "linux"
case "windows", "win32nt":
return "windows"
default:
return strings.ToLower(goos)
}
}
func normalizeTargetGOARCH(goarch string) string {
switch strings.ToLower(goarch) {
case "aarch64", "arm64":
return "arm64"
case "x86_64", "amd64":
return "amd64"
default:
return strings.ToLower(goarch)
}
}
// skipIfModelTooLargeForVRAM skips the test when the model's on-disk size
// is larger than OLLAMA_MAX_VRAM by enough that even partial GPU offload
// won't help. Uses the same 0.75x gate as TestPerfModels (model_perf_test.go)
// so vision/audio tests stay runnable on systems where the model is slightly
// over VRAM and a portion legitimately spills to CPU. No-op when
// OLLAMA_MAX_VRAM is unset.
// won't help. The 0.75x gate keeps vision/audio tests runnable on systems
// where the model is slightly over VRAM and a portion legitimately spills to
// CPU. No-op when OLLAMA_MAX_VRAM is unset.
func skipIfModelTooLargeForVRAM(ctx context.Context, t *testing.T, client *api.Client, modelName string) {
t.Helper()
s := os.Getenv("OLLAMA_MAX_VRAM")
+25 -39
View File
@@ -13,17 +13,6 @@ import (
"github.com/ollama/ollama/types/model"
)
// Default set of vision models to test. When OLLAMA_TEST_MODEL is set,
// only that model is tested (with a capability check for vision).
var defaultVisionModels = []string{
"nemotron3:33b",
"gemma4",
"gemma3",
// "llama3.2-vision", // TODO: re-enable when llama.cpp supports mllama.
"qwen2.5vl",
"qwen3-vl:8b",
}
// decodeTestImages returns the test images.
func decodeTestImages(t *testing.T) (abbeyRoad, docs, ollamaHome api.ImageData) {
t.Helper()
@@ -68,22 +57,17 @@ func skipIfNoVisionOverride(t *testing.T) {
// setupVisionModel pulls the model, preloads it, and skips if not GPU-loaded.
func setupVisionModel(ctx context.Context, t *testing.T, client *api.Client, model string) {
t.Helper()
if testModel == "" {
pullOrSkip(ctx, t, client, model)
}
pullOrSkip(ctx, t, client, model)
skipIfModelTooLargeForVRAM(ctx, t, client, model)
requireCapability(ctx, t, client, model, "vision")
err := client.Generate(ctx, &api.GenerateRequest{Model: model}, func(response api.GenerateResponse) error { return nil })
if err != nil {
t.Fatalf("failed to load model %s: %s", model, err)
}
preloadGenerateModel(ctx, t, client, api.GenerateRequest{Model: model})
skipIfNotGPULoaded(ctx, t, client, model, 80)
}
// TestVisionMultiTurn sends an image, gets a response, then asks follow-up
// runVisionMultiTurn sends an image, gets a response, then asks follow-up
// questions about the same image. This verifies that the KV cache correctly
// handles cached image tokens across turns.
func TestVisionMultiTurn(t *testing.T) {
func runVisionMultiTurn(t *testing.T, models []string) {
skipUnderMinVRAM(t, 16)
skipIfNoVisionOverride(t)
@@ -93,8 +77,9 @@ func TestVisionMultiTurn(t *testing.T) {
"llama3.2-vision": "miscounts animals (says 3 instead of 4) on turn 2",
}
for _, model := range testModels(defaultVisionModels) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
skipKnownIntegrationFlake(t, "vision-multiturn", model)
if reason, ok := skipModels[model]; ok && testModel == "" {
t.Skipf("skipping: %s", reason)
}
@@ -151,8 +136,8 @@ func TestVisionMultiTurn(t *testing.T) {
}
}
// TestVisionObjectCounting asks the model to count objects in an image.
func TestVisionObjectCounting(t *testing.T) {
// runVisionObjectCounting asks the model to count objects in an image.
func runVisionObjectCounting(t *testing.T, models []string) {
skipUnderMinVRAM(t, 16)
skipIfNoVisionOverride(t)
@@ -160,8 +145,9 @@ func TestVisionObjectCounting(t *testing.T) {
"llama3.2-vision": "consistently miscounts (says 3 instead of 4)",
}
for _, model := range testModels(defaultVisionModels) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
skipKnownIntegrationFlake(t, "vision-count", model)
if reason, ok := skipModels[model]; ok && testModel == "" {
t.Skipf("skipping: %s", reason)
}
@@ -191,9 +177,9 @@ func TestVisionObjectCounting(t *testing.T) {
}
}
// TestVisionSceneUnderstanding tests whether the model can identify
// runVisionSceneUnderstanding tests whether the model can identify
// cultural references and scene context from an image.
func TestVisionSceneUnderstanding(t *testing.T) {
func runVisionSceneUnderstanding(t *testing.T, models []string) {
skipUnderMinVRAM(t, 16)
skipIfNoVisionOverride(t)
@@ -203,7 +189,7 @@ func TestVisionSceneUnderstanding(t *testing.T) {
"minicpm-v": "too small for cultural reference detection",
}
for _, model := range testModels(defaultVisionModels) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
if reason, ok := skipModels[model]; ok && testModel == "" {
t.Skipf("skipping: %s", reason)
@@ -236,13 +222,13 @@ func TestVisionSceneUnderstanding(t *testing.T) {
}
}
// TestVisionSpatialReasoning tests the model's ability to identify
// runVisionSpatialReasoning tests the model's ability to identify
// objects based on their spatial position in the image.
func TestVisionSpatialReasoning(t *testing.T) {
func runVisionSpatialReasoning(t *testing.T, models []string) {
skipUnderMinVRAM(t, 16)
skipIfNoVisionOverride(t)
for _, model := range testModels(defaultVisionModels) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
@@ -274,13 +260,13 @@ func TestVisionSpatialReasoning(t *testing.T) {
}
}
// TestVisionDetailRecognition tests whether the model can identify
// runVisionDetailRecognition tests whether the model can identify
// small details like accessories in an image.
func TestVisionDetailRecognition(t *testing.T) {
func runVisionDetailRecognition(t *testing.T, models []string) {
skipUnderMinVRAM(t, 16)
skipIfNoVisionOverride(t)
for _, model := range testModels(defaultVisionModels) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
@@ -310,10 +296,10 @@ func TestVisionDetailRecognition(t *testing.T) {
}
}
// TestVisionMultiImage sends two images in a single message and asks
// runVisionMultiImage sends two images in a single message and asks
// the model to compare and contrast them. This exercises multi-image
// encoding and cross-image reasoning.
func TestVisionMultiImage(t *testing.T) {
func runVisionMultiImage(t *testing.T, models []string) {
skipUnderMinVRAM(t, 16)
skipIfNoVisionOverride(t)
@@ -322,7 +308,7 @@ func TestVisionMultiImage(t *testing.T) {
"llama3.2-vision": "does not support multi-image input",
}
for _, model := range testModels(defaultVisionModels) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
if reason, ok := skipModels[model]; ok && testModel == "" {
t.Skipf("skipping: %s", reason)
@@ -357,14 +343,14 @@ func TestVisionMultiImage(t *testing.T) {
}
}
// TestVisionImageDescription verifies that the model can describe the contents
// runVisionImageDescription verifies that the model can describe the contents
// of the ollama homepage image (a cartoon llama with "Start building with
// open models" text). Basic sanity check that the vision pipeline works.
func TestVisionImageDescription(t *testing.T) {
func runVisionImageDescription(t *testing.T, models []string) {
skipUnderMinVRAM(t, 16)
skipIfNoVisionOverride(t)
for _, model := range testModels(defaultVisionModels) {
for _, model := range testModels(models) {
t.Run(model, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
+23 -2
View File
@@ -186,6 +186,25 @@ llama_model_laguna::graph::graph(const llama_model & model, const llm_graph_para
NULL, LLM_FFN_SILU, LLM_FFN_PAR, il);
cb(cur, "ffn_out", il);
} else {
ggml_tensor * up_scale = nullptr;
float expert_weights_scale = hparams.expert_weights_scale;
#if defined(GGML_USE_METAL)
if (n_tokens >= 32 && ggml_is_quantized(model.layers[il].ffn_down_exps->type)) {
// ggml-metal switches MUL_MAT_ID from its range-safe
// matrix-vector kernel to FP16 matrix tiles at 32 tokens
// (ne21_mm_id_min in ggml_metal_op_mul_mat_id). Laguna's routed
// SwiGLU activations can overflow those tiles. Scale the linear
// up branch and fold the inverse power-of-two factor into the
// existing routing-weight scale. Revisit this guard if the
// Metal dispatch threshold changes.
constexpr float down_input_scale = 1.0f / 256.0f;
up_scale = ggml_fill(ctx0,
model.layers[il].ffn_exp_probs_b, down_input_scale);
expert_weights_scale /= down_input_scale;
}
#endif
ggml_tensor * moe_out = build_moe_ffn(cur,
model.layers[il].ffn_gate_inp,
model.layers[il].ffn_up_exps,
@@ -194,9 +213,11 @@ llama_model_laguna::graph::graph(const llama_model & model, const llm_graph_para
model.layers[il].ffn_exp_probs_b,
n_expert, n_expert_used,
LLM_FFN_SILU, hparams.expert_weights_norm,
hparams.expert_weights_scale,
expert_weights_scale,
(llama_expert_gating_func_type) hparams.expert_gating_func,
il);
il,
nullptr, nullptr,
up_scale);
cb(moe_out, "ffn_moe_out", il);
ggml_tensor * ffn_shexp = build_ffn(cur,
+15 -1
View File
@@ -437,11 +437,25 @@ if(OLLAMA_RUNNER_DIR)
# Bundle GPU runtime libraries (cublas, cudart, rocblas, etc.)
# These are needed at runtime by the GPU backend .so
if(GGML_CUDA AND CUDAToolkit_FOUND)
if(DEFINED OLLAMA_WINDOWS_RUNTIME_ARCH)
string(TOLOWER "${OLLAMA_WINDOWS_RUNTIME_ARCH}" _cuda_redist_arch)
if(_cuda_redist_arch MATCHES "^(x64|amd64|x86_64)$")
set(_cuda_redist_arch "x64")
elseif(_cuda_redist_arch MATCHES "^(arm64|aarch64)$")
set(_cuda_redist_arch "arm64")
endif()
elseif(CMAKE_SYSTEM_PROCESSOR MATCHES "^(ARM64|arm64|aarch64)$")
set(_cuda_redist_arch "arm64")
else()
set(_cuda_redist_arch "x64")
endif()
set(_cuda_redist_dir "${CUDAToolkit_BIN_DIR}/${_cuda_redist_arch}")
# Find the actual ggml-cuda target to get its runtime dependencies
if(TARGET ggml-cuda)
install(TARGETS ggml-cuda
RUNTIME_DEPENDENCIES
DIRECTORIES ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR}
DIRECTORIES ${_cuda_redist_dir} ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_LIBRARY_DIR}
PRE_INCLUDE_REGEXES cublas cublasLt cudart
PRE_EXCLUDE_REGEXES ".*"
RUNTIME DESTINATION "${_base_dest}/${OLLAMA_RUNNER_DIR}" COMPONENT llama-server
+17 -1
View File
@@ -68,7 +68,7 @@
"inherits": ["llama_cuda_v12_base"],
"binaryDir": "${sourceDir}/../../build/llama-server-cuda_v12",
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "50-virtual;52-virtual;60;61;70;75;80;86;89;90;90a;120"
"CMAKE_CUDA_ARCHITECTURES": "50-virtual;52-virtual;60;61;70;75;80;86;89;90;90a;100;120"
}
},
{
@@ -111,6 +111,17 @@
"CMAKE_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;100-virtual;120-virtual"
}
},
{
"name": "llama_cuda_v13_windows_arm64",
"inherits": ["llama_cuda_v13_base"],
"binaryDir": "${sourceDir}/../../build/llama-server-cuda_v13_arm64",
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "121",
"GGML_CPU": "OFF",
"CMAKE_CUDA_FLAGS": "-target-dir=arm64 -t 4",
"OLLAMA_WINDOWS_RUNTIME_ARCH": "arm64"
}
},
{
"name": "llama_cuda_v13_user_arch",
"inherits": ["llama_cuda_v13_base"],
@@ -267,6 +278,11 @@
"configurePreset": "llama_cuda_v13_windows",
"targets": ["ggml-cuda"]
},
{
"name": "llama_cuda_v13_windows_arm64",
"configurePreset": "llama_cuda_v13_windows_arm64",
"targets": ["ggml-cuda"]
},
{
"name": "llama_cuda_v13_user_arch",
"configurePreset": "llama_cuda_v13_user_arch",
+9
View File
@@ -390,6 +390,15 @@ func startLlamaServer(launch llamaServerLaunchConfig, out io.Writer) (cmd *exec.
params = append(params, "--no-mmap")
}
// Direct I/O skips the page cache on load for integrated CUDA/ROCm GPUs, which
// share system memory with the CPU and would otherwise double-buffer weights.
for _, g := range launch.gpus {
if runtime.GOOS == "linux" && g.Integrated && (strings.EqualFold(g.Library, "CUDA") || strings.EqualFold(g.Library, "ROCm")) {
params = append(params, "--direct-io")
break
}
}
// KV cache type
if launch.kvCacheType != "" {
params = append(params, "--cache-type-k", launch.kvCacheType, "--cache-type-v", launch.kvCacheType)
+11
View File
@@ -94,6 +94,17 @@ func (p *LagunaParser) Init(tools []api.Tool, lastMessage *api.Message, thinkVal
return tools
}
// LagunaV8Parser matches the v8 renderer, which closes any assistant history
// turn and emits a fresh assistant generation prompt instead of continuing the
// final assistant message in place.
type LagunaV8Parser struct {
LagunaParser
}
func (p *LagunaV8Parser) Init(tools []api.Tool, _ *api.Message, thinkValue *api.ThinkValue) []api.Tool {
return p.LagunaParser.Init(tools, nil, thinkValue)
}
func (p *LagunaParser) Add(s string, done bool) (content string, thinking string, calls []api.ToolCall, err error) {
p.buffer.WriteString(s)
var contentSB, thinkingSB strings.Builder
+124
View File
@@ -20,6 +20,23 @@ func lagunaTestTools() []api.Tool {
}}
}
func lagunaParseChunks(t *testing.T, parser Parser, chunks ...string) (string, string, []api.ToolCall) {
t.Helper()
var content, thinking string
var calls []api.ToolCall
for i, chunk := range chunks {
chunkContent, chunkThinking, chunkCalls, err := parser.Add(chunk, i == len(chunks)-1)
if err != nil {
t.Fatalf("Add(%q, done=%t): %v", chunk, i == len(chunks)-1, err)
}
content += chunkContent
thinking += chunkThinking
calls = append(calls, chunkCalls...)
}
return content, thinking, calls
}
func TestLagunaParserToolCall(t *testing.T) {
parser := ParserForName("laguna")
if parser == nil {
@@ -515,6 +532,27 @@ func TestLagunaParserNonAssistantLastMessageStillPrimesThinking(t *testing.T) {
}
}
func TestLagunaV8ParserAssistantHistoryStillPrimesThinking(t *testing.T) {
// Laguna v8 closes assistant history and emits a fresh generation prompt,
// so an assistant tail message must not switch the parser into prefill mode.
parser := ParserForName("laguna-v8")
if parser == nil {
t.Fatal("expected laguna-v8 parser")
}
if !parser.HasToolSupport() || !parser.HasThinkingSupport() {
t.Fatal("laguna-v8 parser should advertise tools and thinking")
}
parser.Init(nil, &api.Message{Role: "assistant", Content: "Previous."}, &api.ThinkValue{Value: true})
content, thinking, calls, err := parser.Add("Reasoning.</think>Answer.", true)
if err != nil {
t.Fatal(err)
}
if content != "Answer." || thinking != "Reasoning." || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserStripsLeadingContentWhitespace(t *testing.T) {
// No-think prompts prime </think>, so the model emits a leading newline
// before content; the parser drops it.
@@ -575,3 +613,89 @@ func TestLagunaParserSplitToolTag(t *testing.T) {
t.Fatalf("second chunk content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserPartialToolCallFakeoutInContent(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, nil)
content, thinking, calls := lagunaParseChunks(t, parser, "Document literal <tool_call", " fakeout")
if content != "Document literal <tool_call fakeout" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserPartialToolCallFakeoutInThinking(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(lagunaTestTools(), nil, &api.ThinkValue{Value: true})
content, thinking, calls := lagunaParseChunks(t, parser, "<think>Document literal <tool_c", " fakeout")
if content != "" || thinking != "Document literal <tool_c fakeout" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
}
func TestLagunaParserPartialThinkOpenFakeoutInContent(t *testing.T) {
tests := []struct {
name string
thinkValue *api.ThinkValue
last *api.Message
}{
{
name: "default off",
},
{
name: "explicit off",
thinkValue: &api.ThinkValue{Value: false},
},
{
name: "enabled assistant prefill content",
thinkValue: &api.ThinkValue{Value: true},
last: &api.Message{Role: "assistant", Content: "prefill"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, tt.last, tt.thinkValue)
content, thinking, calls := lagunaParseChunks(t, parser, "Document literal <think", " fakeout")
if content != "Document literal <think fakeout" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
})
}
}
func TestLagunaParserPartialThinkCloseFakeoutAtContentStart(t *testing.T) {
tests := []struct {
name string
thinkValue *api.ThinkValue
last *api.Message
}{
{
name: "default off",
},
{
name: "explicit off",
thinkValue: &api.ThinkValue{Value: false},
},
{
name: "enabled assistant prefill content",
thinkValue: &api.ThinkValue{Value: true},
last: &api.Message{Role: "assistant", Content: "prefill"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parser := ParserForName("laguna")
parser.Init(nil, tt.last, tt.thinkValue)
content, thinking, calls := lagunaParseChunks(t, parser, "</think", " fakeout")
if content != "</think fakeout" || thinking != "" || len(calls) != 0 {
t.Fatalf("content=%q thinking=%q calls=%d", content, thinking, len(calls))
}
})
}
}
+2
View File
@@ -94,6 +94,8 @@ func ParserForName(name string) Parser {
return &LFM2Parser{hasThinkingSupport: true}
case "laguna":
return &LagunaParser{}
case "laguna-v8":
return &LagunaV8Parser{}
case "cohere":
return &CohereParser{}
default:
+137 -3
View File
@@ -82,15 +82,16 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
sb.WriteString(content)
sb.WriteString("\n</user>\n")
case "assistant":
content, reasoning := lagunaV2AssistantContent(message.Content, message.Thinking)
lastMessage := i == len(messages)-1
prefill := lastMessage && (strings.TrimSpace(content) != "" || strings.TrimSpace(message.Thinking) != "" || len(message.ToolCalls) > 0)
prefill := lastMessage && (strings.TrimSpace(content) != "" || strings.TrimSpace(reasoning) != "" || len(message.ToolCalls) > 0)
sb.WriteString("<assistant>\n")
// Every assistant turn opens with the reasoning block: a full
// <think>…</think> when there is reasoning, otherwise a bare
// </think> marking the turn as direct.
if reasoning := strings.TrimSpace(message.Thinking); reasoning != "" {
if reasoning := strings.TrimSpace(reasoning); reasoning != "" {
sb.WriteString("<think>\n")
sb.WriteString(reasoning)
sb.WriteString("\n</think>\n")
@@ -112,7 +113,7 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
sb.WriteString(name)
sb.WriteString("</arg_key>\n")
sb.WriteString("<arg_value>")
sb.WriteString(formatToolCallArgument(value))
sb.WriteString(formatLagunaToolCallArgument(value))
sb.WriteString("</arg_value>\n")
}
sb.WriteString("</tool_call>\n")
@@ -146,3 +147,136 @@ func (r *LagunaRenderer) Render(messages []api.Message, tools []api.Tool, think
return sb.String(), nil
}
func lagunaV2AssistantContent(content, reasoning string) (string, string) {
parts := strings.Split(content, lagunaThoughtClose)
if len(parts) == 1 {
return content, reasoning
}
if reasoning == "" {
before := strings.TrimRight(parts[0], "\n")
if i := strings.LastIndex(before, lagunaThoughtOpen); i >= 0 {
before = before[i+len(lagunaThoughtOpen):]
}
reasoning = strings.TrimLeft(before, "\n")
}
content = strings.TrimLeft(parts[len(parts)-1], "\n")
return content, reasoning
}
type LagunaV8Renderer struct{}
func (r *LagunaV8Renderer) LeadingBOS() string {
return lagunaBOS
}
func (r *LagunaV8Renderer) Render(messages []api.Message, tools []api.Tool, think *api.ThinkValue) (string, error) {
var sb strings.Builder
sb.WriteString(lagunaBOS)
thinkingEnabled := think != nil && think.Bool()
systemMessage := lagunaDefaultSystem
firstMessageIsSystem := len(messages) > 0 && messages[0].Role == "system"
if firstMessageIsSystem {
systemMessage = messages[0].Content
}
hasSystem := strings.TrimSpace(systemMessage) != ""
if hasSystem || len(tools) > 0 || thinkingEnabled {
sb.WriteString("<system>")
if hasSystem {
sb.WriteString(strings.TrimRightFunc(systemMessage, unicode.IsSpace))
if len(tools) > 0 {
sb.WriteString("\n\n")
}
}
if len(tools) > 0 {
sb.WriteString("### Tools\n\n")
sb.WriteString("You may call functions to assist with the user query.\n")
sb.WriteString("All available function signatures are listed below:\n")
sb.WriteString("<available_tools>\n")
for _, tool := range tools {
if b, err := marshalWithSpaces(tool); err == nil {
sb.Write(b)
sb.WriteByte('\n')
}
}
sb.WriteString("</available_tools>")
}
sb.WriteString("</system>\n")
}
for i, message := range messages {
if i == 0 && firstMessageIsSystem {
continue
}
content := message.Content
switch message.Role {
case "user":
sb.WriteString("<user>")
sb.WriteString(content)
sb.WriteString("</user>\n")
case "assistant":
sb.WriteString("<assistant>")
if thinkingEnabled {
sb.WriteString(lagunaThoughtOpen)
sb.WriteString(message.Thinking)
sb.WriteString(lagunaThoughtClose)
} else {
sb.WriteString(lagunaThoughtClose)
}
if content != "" {
sb.WriteString(content)
}
for _, toolCall := range message.ToolCalls {
sb.WriteString("<tool_call>")
sb.WriteString(toolCall.Function.Name)
for name, value := range toolCall.Function.Arguments.All() {
sb.WriteString("<arg_key>")
sb.WriteString(name)
sb.WriteString("</arg_key>")
sb.WriteString("<arg_value>")
sb.WriteString(formatLagunaToolCallArgument(value))
sb.WriteString("</arg_value>")
}
sb.WriteString("</tool_call>")
}
sb.WriteString("</assistant>\n")
case "tool":
sb.WriteString("<tool_response>")
sb.WriteString(content)
sb.WriteString("</tool_response>\n")
case "system":
sb.WriteString("<system>")
sb.WriteString(content)
sb.WriteString("</system>\n")
}
}
sb.WriteString("<assistant>")
if thinkingEnabled {
sb.WriteString(lagunaThoughtOpen)
} else {
sb.WriteString(lagunaThoughtClose)
}
return sb.String(), nil
}
func formatLagunaToolCallArgument(value any) string {
switch v := value.(type) {
case string:
return v
case []byte:
return string(v)
}
if b, err := marshalWithSpaces(value); err == nil {
return string(b)
}
return formatToolCallArgument(value)
}
+645 -54
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
@@ -11,17 +12,23 @@ import (
"github.com/ollama/ollama/api"
)
const (
lagunaV2Template = "testdata/laguna_v2_chat_template.jinja2"
lagunaV8Template = "testdata/laguna_v8_chat_template.jinja2"
)
// lagunaToolJSON is the get_weather tool as serialized into <available_tools>,
// matching lagunaWeatherTool().
const lagunaToolJSON = `{"type": "function", "function": {"name": "get_weather", "description": "Get weather", "parameters": {"type": "object", "required": ["location"], "properties": {"location": {"type": "string", "description": "City"}}}}}`
const lagunaMathToolJSON = `{"type": "function", "function": {"name": "add", "description": "Add numbers", "parameters": {"type": "object", "required": ["a", "b"], "properties": {"a": {"type": "number", "description": "First number"}, "b": {"type": "number", "description": "Second number"}}}}}`
// TestLagunaRendererReferenceFlowCoverage checks the renderer against the Laguna
// chat template. Each want is byte-for-byte template output (verified by
// rendering chat_template.jinja), except that history tool-calls use the clean
// form — the template leaks Jinja indentation there.
// TestLagunaRendererReferenceFlowCoverage checks the renderer against byte-for-byte
// expected output from the Laguna v2 chat template. VERIFY_JINJA2=1 also verifies
// these expected values against the checked-in Jinja fixture.
func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
weather := lagunaWeatherTool()
think := func(v bool) *api.ThinkValue { return &api.ThinkValue{Value: v} }
verifyJinja2 := lagunaVerifyJinja2(t)
// system header is always emitted; with no system message the default is used
defaultHeader := "〈|EOS|〉<system>\n\n" + lagunaDefaultSystem + "\n</system>\n"
@@ -33,6 +40,10 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
think *api.ThinkValue
want string
}{
{
name: "empty_messages",
want: defaultHeader + "<assistant>\n</think>",
},
{
name: "user_only_default",
messages: []api.Message{{Role: "user", Content: "Hello"}},
@@ -59,6 +70,14 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
want: "〈|EOS|〉<system>\n\nStay concise.\n</system>\n" +
"<user>\nHi\n</user>\n<assistant>\n</think>",
},
{
name: "empty_first_system_opts_out_of_header",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Hi"},
},
want: "〈|EOS|〉<user>\nHi\n</user>\n<assistant>\n</think>",
},
{
name: "additional_system",
messages: []api.Message{
@@ -71,6 +90,22 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
"<system>\nSecondary.\n</system>\n" +
"<assistant>\n</think>",
},
{
name: "empty_first_system_with_tools",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Weather?"},
},
tools: weather,
want: "〈|EOS|〉<system>\n\n\n### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n</available_tools>\n\n" +
"For each function call, return an unescaped XML-like object with function name and arguments within '<tool_call>' and '</tool_call>' tags, like here:\n" +
"<tool_call>function-name\n<arg_key>argument-key</arg_key>\n<arg_value>value-of-argument-key</arg_value>\n</tool_call>" +
"\n</system>\n" +
"<user>\nWeather?\n</user>\n<assistant>\n</think>",
},
{
name: "tools_in_header",
messages: []api.Message{
@@ -102,6 +137,19 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
"\n</system>\n" +
"<user>\nWeather?\n</user>\n<assistant>\n</think>",
},
{
name: "multiple_tools_in_header",
messages: []api.Message{{Role: "user", Content: "Add then report weather"}},
tools: append(weather, lagunaMathTool()...),
want: "〈|EOS|〉<system>\n\n" + lagunaDefaultSystem + "\n\n### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n" + lagunaMathToolJSON + "\n</available_tools>\n\n" +
"For each function call, return an unescaped XML-like object with function name and arguments within '<tool_call>' and '</tool_call>' tags, like here:\n" +
"<tool_call>function-name\n<arg_key>argument-key</arg_key>\n<arg_value>value-of-argument-key</arg_value>\n</tool_call>" +
"\n</system>\n" +
"<user>\nAdd then report weather\n</user>\n<assistant>\n</think>",
},
{
name: "assistant_history",
messages: []api.Message{
@@ -138,12 +186,80 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
"<user>\nThanks\n</user>\n<assistant>\n<think>",
},
{
name: "final_assistant_prefill",
name: "assistant_extracts_thinking_from_content",
messages: []api.Message{
{Role: "user", Content: "Complete this"},
{Role: "assistant", Content: "Partial"},
{Role: "user", Content: "Explain"},
{Role: "assistant", Content: "<think>\nPlan\n</think>\nAnswer\n\n"},
{Role: "user", Content: "Next"},
},
want: defaultHeader + "<user>\nComplete this\n</user>\n<assistant>\n</think>\nPartial\n",
think: think(true),
want: defaultHeader +
"<user>\nExplain\n</user>\n" +
"<assistant>\n<think>\nPlan\n</think>\nAnswer\n</assistant>\n" +
"<user>\nNext\n</user>\n<assistant>\n<think>",
},
{
name: "assistant_thinking_metadata_overrides_content_tags",
messages: []api.Message{
{Role: "user", Content: "Explain"},
{Role: "assistant", Thinking: "Use metadata.", Content: "<think>Ignore this</think>\nAnswer"},
{Role: "user", Content: "Next"},
},
want: defaultHeader +
"<user>\nExplain\n</user>\n" +
"<assistant>\n<think>\nUse metadata.\n</think>\nAnswer\n</assistant>\n" +
"<user>\nNext\n</user>\n<assistant>\n</think>",
},
{
name: "assistant_whitespace_content_only",
messages: []api.Message{
{Role: "user", Content: "Continue"},
{Role: "assistant", Content: " \n\t "},
{Role: "user", Content: "Next"},
},
want: defaultHeader +
"<user>\nContinue\n</user>\n" +
"<assistant>\n</think>\n</assistant>\n" +
"<user>\nNext\n</user>\n<assistant>\n</think>",
},
{
name: "assistant_multiple_tool_calls_mixed_args",
messages: []api.Message{
{Role: "user", Content: "Do calls"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{Function: api.ToolCallFunction{
Name: "echo",
Arguments: testArgsOrdered([]orderedArg{
{Key: "text", Value: "hello"},
{Key: "count", Value: 2},
}),
}},
{Function: api.ToolCallFunction{
Name: "configure",
Arguments: testArgsOrdered([]orderedArg{
{Key: "flag", Value: true},
{Key: "options", Value: map[string]any{"mode": "fast"}},
}),
}},
},
},
{Role: "user", Content: "Done?"},
},
want: defaultHeader +
"<user>\nDo calls\n</user>\n" +
"<assistant>\n</think>\n" +
"<tool_call>echo\n" +
"<arg_key>text</arg_key>\n<arg_value>hello</arg_value>\n" +
"<arg_key>count</arg_key>\n<arg_value>2</arg_value>\n" +
"</tool_call>\n" +
"<tool_call>configure\n" +
"<arg_key>flag</arg_key>\n<arg_value>true</arg_value>\n" +
"<arg_key>options</arg_key>\n<arg_value>{\"mode\": \"fast\"}</arg_value>\n" +
"</tool_call>\n" +
"</assistant>\n" +
"<user>\nDone?\n</user>\n<assistant>\n</think>",
},
}
@@ -157,22 +273,346 @@ func TestLagunaRendererReferenceFlowCoverage(t *testing.T) {
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Fatalf("renderer output mismatch vs template (-want +got):\n%s", diff)
}
if verifyJinja2 {
jinja := renderLagunaJinja2Template(t, lagunaV2Template, tt.messages, tt.tools, tt.think)
if diff := cmp.Diff(jinja, tt.want); diff != "" {
t.Fatalf("hardcoded expected mismatch vs Jinja2 template (-jinja +want):\n%s", diff)
}
if diff := cmp.Diff(jinja, got); diff != "" {
t.Fatalf("renderer output mismatch vs Jinja2 template (-jinja +got):\n%s", diff)
}
}
})
}
}
func TestLagunaRendererMatchesLocalJinjaControlFlow(t *testing.T) {
if os.Getenv("VERIFY_LAGUNA_JINJA2") == "" {
t.Skip("set VERIFY_LAGUNA_JINJA2=1 to compare against the local Laguna chat_template.jinja")
func TestLagunaRendererAssistantPrefill(t *testing.T) {
got, err := (&LagunaRenderer{}).Render([]api.Message{
{Role: "user", Content: "Complete this"},
{Role: "assistant", Content: "Partial"},
}, nil, nil)
if err != nil {
t.Fatal(err)
}
python := "/Users/daniel/.codex/worktrees/7038/ollama/.venv/bin/python3"
if _, err := os.Stat(python); err != nil {
t.Fatalf("VERIFY_LAGUNA_JINJA2 requires %s with jinja2 installed", python)
want := "〈|EOS|〉<system>\n\n" + lagunaDefaultSystem + "\n</system>\n" +
"<user>\nComplete this\n</user>\n<assistant>\n</think>\nPartial\n"
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("renderer prefill mismatch (-want +got):\n%s", diff)
}
}
func TestLagunaRendererKnownJinja2Differences(t *testing.T) {
if !lagunaVerifyJinja2(t) {
t.Skip("set VERIFY_JINJA2=1 to run Jinja2 difference checks")
}
messages := []api.Message{
{Role: "user", Content: "Complete this"},
{Role: "assistant", Content: "Partial"},
}
got, err := (&LagunaRenderer{}).Render(messages, nil, nil)
if err != nil {
t.Fatal(err)
}
jinja := renderLagunaJinja2Template(t, lagunaV2Template, messages, nil, nil)
if got == jinja {
t.Fatal("v2 assistant prefill no longer differs from Jinja2 output")
}
wantJinja := "〈|EOS|〉<system>\n\n" + lagunaDefaultSystem + "\n</system>\n" +
"<user>\nComplete this\n</user>\n<assistant>\n</think>\nPartial\n</assistant>\n<assistant>\n</think>"
if diff := cmp.Diff(wantJinja, jinja); diff != "" {
t.Fatalf("v2 assistant prefill Jinja2 reference mismatch (-want +jinja):\n%s", diff)
}
}
func TestLagunaV8RendererReferenceFlowCoverage(t *testing.T) {
weather := lagunaWeatherTool()
think := func(v bool) *api.ThinkValue { return &api.ThinkValue{Value: v} }
verifyJinja2 := lagunaVerifyJinja2(t)
defaultHeader := "〈|EOS|〉<system>" + lagunaDefaultSystem + "</system>\n"
tests := []struct {
name string
messages []api.Message
tools []api.Tool
think *api.ThinkValue
want string
}{
{
name: "empty_messages",
want: defaultHeader + "<assistant></think>",
},
{
name: "user_only_default",
messages: []api.Message{{Role: "user", Content: "Hello"}},
want: defaultHeader + "<user>Hello</user>\n<assistant></think>",
},
{
name: "user_only_think",
messages: []api.Message{{Role: "user", Content: "Hello"}},
think: think(true),
want: defaultHeader + "<user>Hello</user>\n<assistant><think>",
},
{
name: "user_only_nothink",
messages: []api.Message{{Role: "user", Content: "Hello"}},
think: think(false),
want: defaultHeader + "<user>Hello</user>\n<assistant></think>",
},
{
name: "first_system_is_header",
messages: []api.Message{
{Role: "system", Content: "Stay concise.\n\n"},
{Role: "user", Content: "Hi"},
},
want: "〈|EOS|〉<system>Stay concise.</system>\n" +
"<user>Hi</user>\n<assistant></think>",
},
{
name: "empty_first_system_opts_out_of_header",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Hi"},
},
want: "〈|EOS|〉<user>Hi</user>\n<assistant></think>",
},
{
name: "empty_first_system_with_tools",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Weather?"},
},
tools: weather,
want: "〈|EOS|〉<system>" +
"### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n</available_tools>" +
"</system>\n" +
"<user>Weather?</user>\n<assistant></think>",
},
{
name: "empty_first_system_thinking_enabled",
messages: []api.Message{
{Role: "system", Content: ""},
{Role: "user", Content: "Hi"},
},
think: think(true),
want: "〈|EOS|〉<system></system>\n<user>Hi</user>\n<assistant><think>",
},
{
name: "additional_system",
messages: []api.Message{
{Role: "system", Content: "Primary."},
{Role: "user", Content: "Hi"},
{Role: "system", Content: "Secondary."},
},
want: "〈|EOS|〉<system>Primary.</system>\n" +
"<user>Hi</user>\n" +
"<system>Secondary.</system>\n" +
"<assistant></think>",
},
{
name: "tools_in_header",
messages: []api.Message{
{Role: "system", Content: "Stay concise."},
{Role: "user", Content: "Weather?"},
},
tools: weather,
think: think(true),
want: "〈|EOS|〉<system>Stay concise.\n\n" +
"### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n</available_tools>" +
"</system>\n" +
"<user>Weather?</user>\n<assistant><think>",
},
{
name: "tools_default",
messages: []api.Message{{Role: "user", Content: "Weather?"}},
tools: weather,
want: "〈|EOS|〉<system>" + lagunaDefaultSystem + "\n\n" +
"### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n</available_tools>" +
"</system>\n" +
"<user>Weather?</user>\n<assistant></think>",
},
{
name: "multiple_tools_in_header",
messages: []api.Message{{Role: "user", Content: "Add then report weather"}},
tools: append(weather, lagunaMathTool()...),
want: "〈|EOS|〉<system>" + lagunaDefaultSystem + "\n\n" +
"### Tools\n\n" +
"You may call functions to assist with the user query.\n" +
"All available function signatures are listed below:\n" +
"<available_tools>\n" + lagunaToolJSON + "\n" + lagunaMathToolJSON + "\n</available_tools>" +
"</system>\n" +
"<user>Add then report weather</user>\n<assistant></think>",
},
{
name: "assistant_history",
messages: []api.Message{
{Role: "user", Content: "Add these."},
{
Role: "assistant",
Content: "\nCalling the tool.\n",
Thinking: "Need addition.",
ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "add",
Arguments: testArgsOrdered([]orderedArg{
{Key: "a", Value: 2},
{Key: "b", Value: 3},
}),
},
}},
},
{Role: "tool", Content: "5"},
{Role: "user", Content: "Thanks"},
},
think: think(true),
want: defaultHeader +
"<user>Add these.</user>\n" +
"<assistant>" +
"<think>Need addition.</think>" +
"\nCalling the tool.\n" +
"<tool_call>add" +
"<arg_key>a</arg_key><arg_value>2</arg_value>" +
"<arg_key>b</arg_key><arg_value>3</arg_value>" +
"</tool_call>" +
"</assistant>\n" +
"<tool_response>5</tool_response>\n" +
"<user>Thanks</user>\n<assistant><think>",
},
{
name: "assistant_reasoning_ignored_when_thinking_disabled",
messages: []api.Message{
{Role: "user", Content: "Explain"},
{Role: "assistant", Thinking: "Hidden plan.", Content: "Answer"},
{Role: "user", Content: "Next"},
},
want: defaultHeader +
"<user>Explain</user>\n" +
"<assistant></think>Answer</assistant>\n" +
"<user>Next</user>\n<assistant></think>",
},
{
name: "assistant_empty_reasoning_when_thinking_enabled",
messages: []api.Message{
{Role: "user", Content: "Explain"},
{Role: "assistant", Content: "Answer"},
{Role: "user", Content: "Next"},
},
think: think(true),
want: defaultHeader +
"<user>Explain</user>\n" +
"<assistant><think></think>Answer</assistant>\n" +
"<user>Next</user>\n<assistant><think>",
},
{
name: "assistant_preserves_content_whitespace",
messages: []api.Message{
{Role: "user", Content: "Explain"},
{Role: "assistant", Content: "\nAnswer\n"},
{Role: "user", Content: "Next"},
},
want: defaultHeader +
"<user>Explain</user>\n" +
"<assistant></think>\nAnswer\n</assistant>\n" +
"<user>Next</user>\n<assistant></think>",
},
{
name: "assistant_multiple_tool_calls_mixed_args",
messages: []api.Message{
{Role: "user", Content: "Do calls"},
{
Role: "assistant",
ToolCalls: []api.ToolCall{
{Function: api.ToolCallFunction{
Name: "echo",
Arguments: testArgsOrdered([]orderedArg{
{Key: "text", Value: "hello"},
{Key: "count", Value: 2},
}),
}},
{Function: api.ToolCallFunction{
Name: "configure",
Arguments: testArgsOrdered([]orderedArg{
{Key: "flag", Value: true},
{Key: "options", Value: map[string]any{"mode": "fast"}},
}),
}},
},
},
{Role: "user", Content: "Done?"},
},
want: defaultHeader +
"<user>Do calls</user>\n" +
"<assistant></think>" +
"<tool_call>echo" +
"<arg_key>text</arg_key><arg_value>hello</arg_value>" +
"<arg_key>count</arg_key><arg_value>2</arg_value>" +
"</tool_call>" +
"<tool_call>configure" +
"<arg_key>flag</arg_key><arg_value>true</arg_value>" +
"<arg_key>options</arg_key><arg_value>{\"mode\": \"fast\"}</arg_value>" +
"</tool_call>" +
"</assistant>\n" +
"<user>Done?</user>\n<assistant></think>",
},
{
name: "final_assistant_closes_then_generation_prompt",
messages: []api.Message{
{Role: "user", Content: "Complete this"},
{Role: "assistant", Content: "Partial"},
},
want: defaultHeader +
"<user>Complete this</user>\n" +
"<assistant></think>Partial</assistant>\n" +
"<assistant></think>",
},
}
renderer := &LagunaV8Renderer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := renderer.Render(tt.messages, tt.tools, tt.think)
if err != nil {
t.Fatal(err)
}
if diff := cmp.Diff(tt.want, got); diff != "" {
t.Fatalf("renderer output mismatch vs template (-want +got):\n%s", diff)
}
if verifyJinja2 {
jinja := renderLagunaJinja2Template(t, lagunaV8Template, tt.messages, tt.tools, tt.think)
if diff := cmp.Diff(jinja, tt.want); diff != "" {
t.Fatalf("hardcoded expected mismatch vs Jinja2 template (-jinja +want):\n%s", diff)
}
if diff := cmp.Diff(jinja, got); diff != "" {
t.Fatalf("renderer output mismatch vs Jinja2 template (-jinja +got):\n%s", diff)
}
}
})
}
}
func TestLagunaRendererMatchesJinja2ExpandedParity(t *testing.T) {
if os.Getenv("VERIFY_JINJA2") == "" {
t.Skip("set VERIFY_JINJA2=1 to run expanded Jinja2 parity checks")
}
lagunaVerifyJinja2(t)
tests := []struct {
name string
messages []api.Message
tools []api.Tool
think *api.ThinkValue
}{
{
@@ -206,77 +646,198 @@ func TestLagunaRendererMatchesLocalJinjaControlFlow(t *testing.T) {
messages: []api.Message{{Role: "user", Content: "Answer directly."}},
think: &api.ThinkValue{Value: false},
},
{
name: "tools_and_assistant_history",
messages: []api.Message{
{Role: "system", Content: "Stay concise."},
{Role: "user", Content: "Weather?"},
{Role: "assistant", Content: "Calling.", Thinking: "Need weather.", ToolCalls: []api.ToolCall{{
Function: api.ToolCallFunction{
Name: "get_weather",
Arguments: testArgsOrdered([]orderedArg{{Key: "location", Value: "Paris"}}),
},
}}},
{Role: "tool", Content: "Sunny"},
{Role: "user", Content: "Thanks"},
},
tools: lagunaWeatherTool(),
think: &api.ThinkValue{Value: true},
},
}
renderer := &LagunaRenderer{}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := renderer.Render(tt.messages, nil, tt.think)
if err != nil {
t.Fatal(err)
}
for _, modelDir := range []string{
"/Users/daniel/Models/poolside/laguna-xs-23-04-2026",
} {
want := renderLagunaChatTemplate(t, python, modelDir, tt.messages, tt.think)
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("%s mismatch (-chat_template +renderer):\n%s", modelDir, diff)
}
variants := []struct {
name string
renderer Renderer
template string
}{
{name: "v2", renderer: &LagunaRenderer{}, template: lagunaV2Template},
{name: "v8", renderer: &LagunaV8Renderer{}, template: lagunaV8Template},
}
for _, variant := range variants {
t.Run(variant.name, func(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := variant.renderer.Render(tt.messages, tt.tools, tt.think)
if err != nil {
t.Fatal(err)
}
want := renderLagunaJinja2Template(t, variant.template, tt.messages, tt.tools, tt.think)
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("renderer output mismatch vs Jinja2 template (-jinja +got):\n%s", diff)
}
})
}
})
}
}
func renderLagunaChatTemplate(t *testing.T, python, modelDir string, messages []api.Message, think *api.ThinkValue) string {
func lagunaVerifyJinja2(t *testing.T) bool {
t.Helper()
if os.Getenv("VERIFY_JINJA2") == "" {
return false
}
python := lagunaJinjaPython(t)
cmd := exec.Command(python, "-c", "from transformers.utils.chat_template_utils import _compile_jinja_template")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("VERIFY_JINJA2=1 requires transformers chat template support in %s: %v\n%s", python, err, out)
}
return true
}
func lagunaJinjaPython(t *testing.T) string {
t.Helper()
python, err := exec.LookPath("python3")
if err != nil {
t.Fatal("VERIFY_JINJA2=1 requires python3 on PATH")
}
return python
}
func renderLagunaJinja2Template(t *testing.T, templateRelPath string, messages []api.Message, tools []api.Tool, think *api.ThinkValue) string {
t.Helper()
type templateMessage struct {
Role string `json:"role"`
Content string `json:"content"`
templatePath, err := filepath.Abs(templateRelPath)
if err != nil {
t.Fatalf("failed to get template path: %v", err)
}
templateMessages := make([]templateMessage, 0, len(messages))
type jinjaToolCall struct {
Function struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
} `json:"function"`
}
type jinjaMessage struct {
Role string `json:"role"`
Content string `json:"content"`
Reasoning string `json:"reasoning,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
ToolCalls []jinjaToolCall `json:"tool_calls,omitempty"`
}
jinjaMessages := make([]jinjaMessage, 0, len(messages))
for _, msg := range messages {
templateMessages = append(templateMessages, templateMessage{
Role: msg.Role,
Content: msg.Content,
})
jm := jinjaMessage{
Role: msg.Role,
Content: msg.Content,
Reasoning: msg.Thinking,
ReasoningContent: msg.Thinking,
}
for _, call := range msg.ToolCalls {
jc := jinjaToolCall{}
jc.Function.Name = call.Function.Name
raw, err := call.Function.Arguments.MarshalJSON()
if err != nil {
t.Fatalf("failed to marshal tool args: %v", err)
}
jc.Function.Arguments = json.RawMessage(raw)
jm.ToolCalls = append(jm.ToolCalls, jc)
}
jinjaMessages = append(jinjaMessages, jm)
}
messagesJSON, err := json.Marshal(templateMessages)
messagesJSON, err := json.Marshal(jinjaMessages)
if err != nil {
t.Fatalf("failed to marshal messages: %v", err)
}
enableThinking := "False"
if think != nil && think.Bool() {
enableThinking = "True"
toolsJSON := "None"
if len(tools) > 0 {
b, err := json.Marshal(tools)
if err != nil {
t.Fatalf("failed to marshal tools: %v", err)
}
toolsJSON = string(b)
}
enableThinking := "unset"
if think != nil {
if think.Bool() {
enableThinking = "true"
} else {
enableThinking = "false"
}
}
script := `
import json
import sys
from transformers import AutoTokenizer
from pathlib import Path
from transformers.utils.chat_template_utils import _compile_jinja_template
model_dir = sys.argv[1]
messages = json.loads(sys.argv[2])
enable_thinking = sys.argv[3] == "True"
tok = AutoTokenizer.from_pretrained(model_dir, trust_remote_code=True)
print(tok.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
enable_thinking=enable_thinking,
), end="")
template_path, messages_json, tools_json, enable_thinking = sys.argv[1:5]
tmpl = _compile_jinja_template(Path(template_path).read_text())
kwargs = {
"messages": json.loads(messages_json),
"add_generation_prompt": True,
}
if tools_json != "None":
kwargs["tools"] = json.loads(tools_json)
if enable_thinking == "true":
kwargs["enable_thinking"] = True
elif enable_thinking == "false":
kwargs["enable_thinking"] = False
print(tmpl.render(**kwargs), end="")
`
cmd := exec.Command(python, "-c", script, modelDir, string(messagesJSON), enableThinking)
cmd := exec.Command(lagunaJinjaPython(t), "-c", script, templatePath, string(messagesJSON), toolsJSON, enableThinking)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("chat_template render failed: %v\nstderr: %s", err, stderr.String())
t.Fatalf("python render failed: %v\nstderr: %s", err, stderr.String())
}
return stdout.String()
}
func TestLagunaTemplateFixturesMatchExpectedVersions(t *testing.T) {
v2, err := os.ReadFile(lagunaV2Template)
if err != nil {
t.Fatalf("failed to read %s: %v", lagunaV2Template, err)
}
v8, err := os.ReadFile(lagunaV8Template)
if err != nil {
t.Fatalf("failed to read %s: %v", lagunaV8Template, err)
}
if !strings.Contains(string(v2), "laguna_glm_thinking_v5/chat_template.jinja") {
t.Fatalf("%s does not look like the v2 Laguna template fixture", lagunaV2Template)
}
if !strings.Contains(string(v8), "laguna_glm_thinking_v8/chat_template.jinja") {
t.Fatalf("%s does not look like the v8 Laguna template fixture", lagunaV8Template)
}
if !strings.Contains(string(v2), "render_assistant_messages_raw") {
t.Fatalf("%s should retain the v2 raw assistant branch", lagunaV2Template)
}
if strings.Contains(string(v8), "render_assistant_messages_raw") {
t.Fatalf("%s unexpectedly contains the v2 raw assistant branch", lagunaV8Template)
}
if diff := cmp.Diff(string(v2), string(v8)); diff == "" {
t.Fatal("Laguna v2 and v8 template fixtures unexpectedly match")
}
}
func lagunaWeatherTool() []api.Tool {
return []api.Tool{{
Type: "function",
@@ -297,3 +858,33 @@ func lagunaWeatherTool() []api.Tool {
},
}}
}
func lagunaMathTool() []api.Tool {
return []api.Tool{{
Type: "function",
Function: api.ToolFunction{
Name: "add",
Description: "Add numbers",
Parameters: api.ToolFunctionParameters{
Type: "object",
Required: []string{"a", "b"},
Properties: testPropsOrdered([]orderedProp{
{
Key: "a",
Value: api.ToolProperty{
Type: api.PropertyType{"number"},
Description: "First number",
},
},
{
Key: "b",
Value: api.ToolProperty{
Type: api.PropertyType{"number"},
Description: "Second number",
},
},
}),
},
},
}}
}
+2
View File
@@ -109,6 +109,8 @@ func rendererForName(name string) Renderer {
return &LFM2Renderer{IsThinking: true, useImgTags: RenderImgTags}
case "laguna":
return &LagunaRenderer{}
case "laguna-v8":
return &LagunaV8Renderer{}
case "cohere":
return &CohereRenderer{}
default:
+1
View File
@@ -69,6 +69,7 @@ func TestLeadingBOSForRenderer(t *testing.T) {
{name: "lfm2", want: "<|startoftext|>"},
{name: "lfm2-thinking", want: "<|startoftext|>"},
{name: "laguna", want: "〈|EOS|〉"},
{name: "laguna-v8", want: "〈|EOS|〉"},
{name: "deepseek3.1", want: "<begin▁of▁sentence>"},
{name: "cogito", want: "<begin▁of▁sentence>"},
{name: "qwen3-coder", want: ""},
+132
View File
@@ -0,0 +1,132 @@
{#- Iteration on laguna_glm_thinking_v5/chat_template.jinja -#}
{#- Adds a default system message (used when no system message is provided in `messages`). -#}
{{- "〈|EOS|〉" -}}
{%- set enable_thinking = enable_thinking | default(false) -%}
{%- set render_assistant_messages_raw = render_assistant_messages_raw | default(false) -%}
{%- set add_generation_prompt = add_generation_prompt | default(false) -%}
{#- ───── header (system message) ───── -#}
{%- set system_message = "You are a helpful, conversationally-fluent assistant made by Poolside. You are here to be helpful to users through natural language conversations." -%}
{%- if messages and messages[0].role == "system" -%}
{%- set system_message = messages[0].content -%}
{%- endif -%}
{%- if (system_message and system_message.strip()) or tools -%}
{{- "<system>\n" -}}
{%- if system_message and system_message.strip() -%}
{{- "\n" -}}
{{- system_message.rstrip() -}}
{%- endif -%}
{%- if tools -%}
{{- "\n\n### Tools\n\n" -}}
{%- set ns = namespace(tool_string="You may call functions to assist with the user query.\n"
~ "All available function signatures are listed below:\n"
~ "<available_tools>\n") -%}
{%- for tool in tools -%}
{%- set ns.tool_string = ns.tool_string ~ (tool | tojson) ~ "\n" -%}
{%- endfor -%}
{%- if enable_thinking -%}
{%- set tool_string = ns.tool_string + "</available_tools>\n\n" ~
"Wrap your thinking in '<think>', '</think>' tags, followed by a function call. For each function call, return an unescaped XML-like object with function name and arguments within '<tool_call>' and '</tool_call>' tags, like here:\n" ~
"<think> your thoughts here </think>\n" ~
"<tool_call>function-name\n<arg_key>argument-key</arg_key>\n<arg_value>value-of-argument-key</arg_value>\n" ~
"</tool_call>" -%}
{%- else -%}
{%- set tool_string = ns.tool_string + "</available_tools>\n\n" ~
"For each function call, return an unescaped XML-like object " ~
"with function name and arguments within '<tool_call>' and '</tool_call>' tags, like here:\n" ~
"<tool_call>function-name\n<arg_key>argument-key</arg_key>\n<arg_value>value-of-argument-key</arg_value>\n" ~
"</tool_call>" -%}
{%- endif -%}
{{- tool_string -}}
{%- endif -%}
{{- "\n</system>\n" -}}
{%- endif -%}
{#- ───── main loop ───── -#}
{%- for message in messages -%}
{%- set content = message.content if message.content is string else "" -%}
{%- if message.role == "user" -%}
{{- "<user>\n" + content + "\n</user>\n" -}}
{%- elif message.role == "assistant" -%}
{%- generation -%}
{{- "<assistant>\n" -}}
{%- if render_assistant_messages_raw -%}
{#- Raw mode: prepend the generation prompt token, then dump content verbatim. -#}
{#- The generation prompt is <think> when enable_thinking, </think> otherwise. -#}
{#- Only prepend if content doesn't already start with it. -#}
{%- if enable_thinking -%}
{%- if not content.startswith('<think>') -%}
{{- '<think>' -}}
{%- endif -%}
{%- else -%}
{%- if not content.startswith('</think>') -%}
{{- '</think>' -}}
{%- endif -%}
{%- endif -%}
{{- content -}}
{#- Append closing tag if content doesn't already end with it. -#}
{%- if not content.endswith('</assistant>\n') and not content.endswith('</assistant>') -%}
{{- '\n</assistant>' -}}
{%- endif -%}
{{- "\n" -}}
{%- else -%}
{#- Extract reasoning content from message.reasoning (vLLM field name) or message.reasoning_content, or from <think> tags -#}
{%- set reasoning_content = '' %}
{%- if message.reasoning is string %}
{%- set reasoning_content = message.reasoning %}
{%- elif message.reasoning_content is string %}
{%- set reasoning_content = message.reasoning_content %}
{%- endif %}
{#- Always strip <think> tags from content if present to avoid duplication -#}
{%- if '</think>' in content %}
{%- if not reasoning_content %}
{%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
{%- endif %}
{%- set content = content.split('</think>')[-1].lstrip('\n') %}
{%- endif %}
{#- Display reasoning content for all messages -#}
{%- if reasoning_content -%}
{{- '<think>\n' + reasoning_content.strip() + '\n</think>\n' -}}
{%- else -%}
{{- '</think>\n' -}}
{%- endif -%}
{#- Display main content -#}
{%- if content.strip() -%}
{{- content.strip() ~ "\n" -}}
{%- endif -%}
{%- if message.tool_calls -%}
{%- for tool_call in message.tool_calls -%}
{%- set function_data = tool_call.function -%}
{{- '<tool_call>' + function_data.name }}
{% set _args = function_data.arguments %}
{%- for k, v in _args.items() -%}
{{- "<arg_key>" ~ k ~ "</arg_key>\n" -}}
{{- "<arg_value>"}}{{ v | tojson(ensure_ascii=False) if v is not string else v }}{{ "</arg_value>\n" -}}
{%- endfor -%}
{{- "</tool_call>\n" -}}
{%- endfor -%}
{%- endif -%}
{{- "</assistant>\n" -}}
{%- endif -%}
{%- endgeneration -%}
{%- elif message.role == "tool" -%}
{{- "<tool_response>\n" + content + "\n</tool_response>\n" -}}
{%- elif message.role == "system" and loop.index0 != 0 -%}
{#- Render additional system messages (skip the first one which is handled separately in the header) -#}
{{- "<system>\n" + content + "\n</system>\n" -}}
{%- endif -%}
{%- endfor -%}
{#- ───── generation prompt ───── -#}
{%- if add_generation_prompt -%}
{{- "<assistant>\n" -}}
{#- ───── Include reasoning mode directive ───── -#}
{%- if not enable_thinking %}
{{- '</think>' -}}
{%- else %}
{{- '<think>' -}}
{%- endif %}
{%- endif -%}
+93
View File
@@ -0,0 +1,93 @@
{#- Iteration on laguna_glm_thinking_v8/chat_template.jinja -#}
{#- No formatting instructions -#}
{{- "〈|EOS|〉" -}}
{%- set enable_thinking = enable_thinking | default(false) -%}
{%- set add_generation_prompt = add_generation_prompt | default(false) -%}
{#- ───── header (system message) ───── -#}
{#- A caller-supplied system message with empty content opts out of the default below, producing no <system> block — used to train without a system message. -#}
{%- set system_message = "You are a helpful, conversationally-fluent assistant made by Poolside. You are here to be helpful to users through natural language conversations." -%}
{%- if messages and messages[0].role == "system" -%}
{%- set system_message = messages[0].content -%}
{%- set messages = messages[1:] -%}
{%- endif -%}
{%- set has_sys = system_message and system_message.strip() -%}
{%- if has_sys or tools or enable_thinking -%}
{{- "<system>" -}}
{%- if has_sys -%}
{{- system_message.rstrip() -}}
{%- if tools -%}{{- "\n\n" -}}{%- endif -%}
{%- endif -%}
{%- if tools -%}
{{- "### Tools\n\n" -}}
{{- "You may call functions to assist with the user query.\n" -}}
{{- "All available function signatures are listed below:\n" -}}
{{- "<available_tools>\n" -}}
{%- for tool in tools -%}
{{- (tool | tojson) ~ "\n" -}}
{%- endfor -%}
{{- "</available_tools>" -}}
{%- endif -%}
{{- "</system>\n" -}}
{%- endif -%}
{#- ───── main loop ───── -#}
{%- for message in messages -%}
{%- set content = message.content if message.content is string else "" -%}
{%- if message.role == "user" -%}
{{- "<user>" + content + "</user>\n" -}}
{%- elif message.role == "assistant" -%}
{%- generation -%}
{{- "<assistant>" -}}
{#- Extract reasoning content from message.reasoning (vLLM field name) or message.reasoning_content -#}
{%- set reasoning_content = '' -%}
{%- if message.reasoning is string -%}
{%- set reasoning_content = message.reasoning -%}
{%- elif message.reasoning_content is string -%}
{%- set reasoning_content = message.reasoning_content -%}
{%- endif -%}
{#- Display reasoning content for all messages if enable_thinking -#}
{%- if enable_thinking -%}
{{- '<think>' + reasoning_content + '</think>' -}}
{%- else -%}
{{- '</think>' -}}
{%- endif -%}
{#- Display main content (trailing newline only when no tool_calls follow) -#}
{%- if content -%}
{{- content -}}
{%- endif -%}
{%- if message.tool_calls -%}
{%- for tool_call in message.tool_calls -%}
{%- set function_data = tool_call.function -%}
{{- '<tool_call>' + function_data.name -}}
{%- set _args = function_data.arguments -%}
{%- for k, v in _args.items() -%}
{{- "<arg_key>" ~ k ~ "</arg_key>" -}}
{{- "<arg_value>" -}}{{- v | tojson(ensure_ascii=False) if v is not string else v -}}{{- "</arg_value>" -}}
{%- endfor -%}
{{- "</tool_call>" -}}
{%- endfor -%}
{%- endif -%}
{{- "</assistant>\n" -}}
{%- endgeneration -%}
{%- elif message.role == "tool" -%}
{{- "<tool_response>" + content + "</tool_response>\n" -}}
{%- elif message.role == "system" -%}
{#- Render additional system messages (the first one, if any, is handled separately in the header and was sliced off above) -#}
{{- "<system>" + content + "</system>\n" -}}
{%- endif -%}
{%- endfor -%}
{#- ───── generation prompt ───── -#}
{%- if add_generation_prompt -%}
{{- "<assistant>" -}}
{#- ───── Include reasoning mode directive ───── -#}
{%- if enable_thinking -%}
{{- '<think>' -}}
{%- else -%}
{{- '</think>' -}}
{%- endif -%}
{%- endif -%}
+8
View File
@@ -380,6 +380,14 @@ func filesForModel(path string) ([]string, error) {
}
files = append(files, js...)
// Transformers stores a tokenizer's default template in this standalone
// file when it is not embedded in tokenizer_config.json.
chatTemplates, err := glob(filepath.Join(path, "chat_template.jinja"), "text/plain")
if err != nil {
return nil, err
}
files = append(files, chatTemplates...)
// add tokenizer.model if it exists (tokenizer.json is automatically picked up by the previous glob)
// tokenizer.model might be a unresolved git lfs reference; error if it is
if tks, _ := glob(filepath.Join(path, "tokenizer.model"), "application/octet-stream"); len(tks) > 0 {
+2
View File
@@ -936,6 +936,7 @@ func TestFilesForModel(t *testing.T) {
"model-00002-of-00002.safetensors",
"config.json",
"tokenizer.json",
"chat_template.jinja",
}
for _, file := range files {
if err := os.WriteFile(filepath.Join(dir, file), []byte("test content"), 0o644); err != nil {
@@ -949,6 +950,7 @@ func TestFilesForModel(t *testing.T) {
"model-00002-of-00002.safetensors",
"config.json",
"tokenizer.json",
"chat_template.jinja",
},
},
{
+405 -64
View File
@@ -64,6 +64,16 @@ function normalizePathForCompare {
return ([IO.Path]::GetFullPath($Path).TrimEnd('\')).Replace('/', '\').ToLowerInvariant()
}
function convertToCMakePath {
param([string]$Path)
if (-not $Path) {
return $Path
}
return ([IO.Path]::GetFullPath($Path)).Replace('\', '/')
}
function newCompilerPair($name, $cc, $cxx) {
if ((Test-Path $cc) -and (Test-Path $cxx)) {
return [pscustomobject]@{
@@ -99,39 +109,123 @@ function findWindowsCPUCompiler {
return $null
}
function msvcArchName {
param([string]$Arch)
switch -Regex ($Arch) {
"^(arm64|aarch64)$" { return "arm64" }
"^(amd64|x64|x86_64)$" { return "x64" }
default { return $Arch }
}
}
function hostMsvcArchName {
try {
if ((Get-CimInstance Win32_Processor | Select-Object -First 1).Architecture -eq 12) {
return "arm64"
}
} catch {
return "x64"
}
return "x64"
}
function ensureMsvcForNinja {
param(
[string]$TargetArch = $script:ARCH,
[switch]$Optional
)
if ($env:CMAKE_GENERATOR -notlike "Ninja*") {
if ($Optional) { return $true }
return
}
if (-not (Get-Command -Name "cl.exe" -ErrorAction SilentlyContinue)) {
$msvcTargetArch = msvcArchName $TargetArch
$cl = Get-Command -Name "cl.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
$needDevShell = -not $cl
if ($cl -and $cl.Source -notmatch "[\\/]$msvcTargetArch[\\/]cl\.exe$") {
$needDevShell = $true
}
if ($needDevShell) {
$vsInstall = findVisualStudioInstall
if ($vsInstall) {
$devShell = Join-Path $vsInstall "Common7\Tools\Microsoft.VisualStudio.DevShell.dll"
if (Test-Path $devShell) {
Import-Module $devShell
Enter-VsDevShell -VsInstallPath $vsInstall -SkipAutomaticLocation -DevCmdArguments "-arch=x64 -no_logo"
$hostArch = hostMsvcArchName
Enter-VsDevShell -VsInstallPath $vsInstall -SkipAutomaticLocation -DevCmdArguments "-arch=$msvcTargetArch -host_arch=$hostArch -no_logo"
}
}
}
if (-not (Get-Command -Name "cl.exe" -ErrorAction SilentlyContinue)) {
Write-Error "Ninja builds require MSVC cl.exe. Install Visual Studio C++ tools or run from a VS Developer shell."
$cl = Get-Command -Name "cl.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $cl) {
$message = "Ninja builds require MSVC cl.exe. Install Visual Studio C++ tools or run from a VS Developer shell."
if ($Optional) {
Write-Warning $message
return $false
}
Write-Error $message
exit(1)
}
Write-Output "MSVC cl.exe available for Ninja builds"
if ($cl.Source -notmatch "[\\/]$msvcTargetArch[\\/]cl\.exe$") {
$message = "Ninja build for $TargetArch requires MSVC $msvcTargetArch cl.exe, but PATH has $($cl.Source)"
if ($Optional) {
Write-Warning $message
return $false
}
Write-Error $message
exit(1)
}
if ($Optional) {
Write-Host "MSVC $msvcTargetArch cl.exe available for Ninja builds"
return $true
}
Write-Output "MSVC $msvcTargetArch cl.exe available for Ninja builds"
}
function saveEnvironment {
$snapshot = @{}
Get-ChildItem Env: | ForEach-Object {
$snapshot[$_.Name] = $_.Value
}
return $snapshot
}
function restoreEnvironment {
param($Snapshot)
foreach ($item in Get-ChildItem Env:) {
if (-not $Snapshot.ContainsKey($item.Name)) {
Remove-Item "Env:$($item.Name)" -ErrorAction SilentlyContinue
}
}
foreach ($name in $Snapshot.Keys) {
Set-Item "Env:$name" $Snapshot[$name]
}
}
function checkEnv {
if ($null -ne $env:ARCH ) {
$script:ARCH = $env:ARCH
} else {
$arch=([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
if ($null -ne $arch) {
$script:ARCH = ($arch.ToString().ToLower()).Replace("x64", "amd64")
} else {
Write-Output "WARNING: old powershell detected, assuming amd64 architecture - set `$env:ARCH to override"
$script:ARCH="amd64"
# RuntimeInformation.OSArchitecture can report X64 on ARM64 Windows
# when PowerShell itself is running under x64 emulation.
$procArch = (Get-CimInstance Win32_Processor).Architecture
switch ($procArch) {
12 { $script:ARCH = "arm64" }
9 { $script:ARCH = "amd64" }
default {
$arch=([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
if ($null -ne $arch) {
$script:ARCH = ($arch.ToString().ToLower()).Replace("x64", "amd64")
} else {
Write-Output "WARNING: old powershell detected, assuming amd64 architecture - set `$env:ARCH to override"
$script:ARCH="amd64"
}
}
}
}
$script:TARGET_ARCH=$script:ARCH
@@ -352,7 +446,9 @@ function cpuArm64 {
New-Item "${arm64DistDir}\lib\ollama\" -ItemType Directory -ea 0 | Out-Null
# Cross-compile the Windows ARM64 CPU llama-server payload from an x64 host
# with llvm-mingw. GPU backends are not built for Windows ARM64.
# with llvm-mingw. Upstream ggml only supports ARM CPU variant matrices on
# Linux, Android, and Apple targets, so build one generic Windows ARM64 CPU
# backend here instead of GGML_CPU_ALL_VARIANTS.
$oldCC = $env:CC
$oldCXX = $env:CXX
$oldGenerator = $env:CMAKE_GENERATOR
@@ -384,52 +480,237 @@ function cudaCMakeArgs {
[string]$cuda
)
$env:CUDACXX = "$cuda\bin\nvcc.exe"
$cudaRoot = convertToCMakePath $cuda
$nvcc = "$cudaRoot/bin/nvcc.exe"
$env:CUDACXX = $nvcc
if ($env:CMAKE_GENERATOR -like "Ninja*") {
return @()
return @("-DCUDAToolkit_ROOT:PATH=$cudaRoot", "-DCMAKE_CUDA_COMPILER:FILEPATH=$nvcc")
}
return @("-T", "cuda=$cuda", "-DCMAKE_CUDA_COMPILER=$cuda\bin\nvcc.exe")
return @("-T", "cuda=$cuda", "-DCUDAToolkit_ROOT:PATH=$cudaRoot", "-DCMAKE_CUDA_COMPILER:FILEPATH=$nvcc")
}
function findCudaRoot {
param (
[string]$MajorVer,
[string]$ExactVer
)
if ($ExactVer) {
$envName = "CUDA_PATH_V$($ExactVer.Replace('.', '_'))"
$candidates = @(
[Environment]::GetEnvironmentVariable($envName),
"C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v$ExactVer"
)
foreach ($candidate in ($candidates | Where-Object { $_ } | Select-Object -Unique)) {
if (Test-Path -LiteralPath (Join-Path $candidate "bin\nvcc.exe")) {
return $candidate
}
}
}
if ("$script:CUDA_DIRS".Contains("v$MajorVer")) {
foreach ($d in $Script:CUDA_DIRS){
if ($d.FullName.Contains("v$MajorVer")) {
if (test-path -literalpath (join-path -path $d -childpath "nvcc.exe" ) ) {
return ($d.FullName|split-path -parent)
}
}
}
}
return $null
}
function cudaArm64CMakeArgs {
param (
[string]$cuda
)
$cudaRoot = convertToCMakePath $cuda
$cudaArm64LibDir = Join-Path $cuda "lib\arm64"
if (-not (Test-Path -LiteralPath $cudaArm64LibDir)) {
Write-Error "CUDA at $cuda is missing Windows ARM64 import libraries under lib\arm64"
exit(1)
}
$requiredLibs = @("cudart.lib", "cudart_static.lib", "cuda.lib", "cublas.lib", "cublasLt.lib")
foreach ($lib in $requiredLibs) {
$libPath = Join-Path $cudaArm64LibDir $lib
if (-not (Test-Path -LiteralPath $libPath)) {
Write-Error "CUDA at $cuda is missing Windows ARM64 import library $libPath"
exit(1)
}
}
$cudaLib = convertToCMakePath $cudaArm64LibDir
$nvcc = "$cudaRoot/bin/nvcc.exe"
$cl = Get-Command -Name "cl.exe" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $cl -or $cl.Source -notmatch "[\\/]arm64[\\/]cl\.exe$") {
Write-Error "CUDA Windows ARM64 builds require MSVC ARM64 cl.exe on PATH"
exit(1)
}
$env:CUDACXX = $nvcc
return @(
"-DCUDAToolkit_ROOT:PATH=$cudaRoot",
"-DCMAKE_CUDA_COMPILER:FILEPATH=$nvcc",
"-DCUDAToolkit_LIBRARY_DIR:PATH=$cudaLib",
"-DCUDA_CUDART:FILEPATH=$cudaLib/cudart.lib",
"-DCUDA_cudart_LIBRARY:FILEPATH=$cudaLib/cudart.lib",
"-DCUDA_cudart_static_LIBRARY:FILEPATH=$cudaLib/cudart_static.lib",
"-DCUDA_cuda_driver_LIBRARY:FILEPATH=$cudaLib/cuda.lib",
"-DCUDA_cublas_LIBRARY:FILEPATH=$cudaLib/cublas.lib",
"-DCUDA_cublasLt_LIBRARY:FILEPATH=$cudaLib/cublasLt.lib"
)
}
function cudaArm64UnavailableReason {
param (
[string]$cuda
)
$cudaArm64LibDir = Join-Path $cuda "lib\arm64"
if (-not (Test-Path -LiteralPath $cudaArm64LibDir)) {
return "missing Windows ARM64 import libraries under lib\arm64"
}
$requiredLibs = @("cudart.lib", "cudart_static.lib", "cuda.lib", "cublas.lib", "cublasLt.lib")
foreach ($lib in $requiredLibs) {
$libPath = Join-Path $cudaArm64LibDir $lib
if (-not (Test-Path -LiteralPath $libPath)) {
return "missing Windows ARM64 import library $libPath"
}
}
return $null
}
function cudaArm64ArchitectureArgs {
if ($env:OLLAMA_WOA_CUDA_ARCHITECTURES) {
Write-Output "Overriding Windows ARM64 CUDA architectures: $env:OLLAMA_WOA_CUDA_ARCHITECTURES"
return @("-DCMAKE_CUDA_ARCHITECTURES=$env:OLLAMA_WOA_CUDA_ARCHITECTURES")
}
return @()
}
function cudaCommon {
param (
[string]$cudaMajorVer
[string]$cudaMajorVer,
[string]$cudaExactVer
)
if ($script:ARCH -eq "arm64") {
Write-Error "Use cudaArm64Common for Windows ARM64 CUDA builds"
exit(1)
}
mkdir -Force -path "${script:DIST_DIR}\" | Out-Null
if ($script:ARCH -ne "arm64") {
if ("$script:CUDA_DIRS".Contains("v$cudaMajorVer")) {
foreach ($d in $Script:CUDA_DIRS){
if ($d.FullName.Contains("v$cudaMajorVer")) {
if (test-path -literalpath (join-path -path $d -childpath "nvcc.exe" ) ) {
$cuda=($d.FullName|split-path -parent)
break
}
}
}
# Build llama-server CUDA backend from upstream source
Write-Output "Building llama-server CUDA v$cudaMajorVer backend"
$env:CUDAToolkit_ROOT=$cuda
$preset = "llama_cuda_v$($cudaMajorVer)_windows"
$cudaToolsetArgs = cudaCMakeArgs $cuda
$configureArgs = @("-S", "llama\server", "--preset", $preset) + $cudaToolsetArgs + @("--install-prefix", "$script:DIST_DIR")
& cmake @configureArgs
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
& cmake --build "build\llama-server-cuda_v$cudaMajorVer" --config Release --parallel $script:JOBS
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
& cmake --install "build\llama-server-cuda_v$cudaMajorVer" --component llama-server --strip
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
$cuda = findCudaRoot $cudaMajorVer $cudaExactVer
if ($cuda) {
# Build llama-server CUDA backend from upstream source
Write-Output "Building llama-server CUDA v$cudaMajorVer backend $cuda"
$preset = "llama_cuda_v$($cudaMajorVer)_windows"
$cudaToolsetArgs = cudaCMakeArgs $cuda
$configureArgs = @("-S", "llama\server", "--preset", $preset) + $cudaToolsetArgs + @("--install-prefix", "$script:DIST_DIR")
& cmake @configureArgs
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
& cmake --build "build\llama-server-cuda_v$cudaMajorVer" --config Release --parallel $script:JOBS
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
& cmake --install "build\llama-server-cuda_v$cudaMajorVer" --component llama-server --strip
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
} else {
if ($cudaExactVer) {
Write-Output "CUDA v$cudaExactVer not detected, skipping"
} else {
Write-Output "CUDA v$cudaMajorVer not detected, skipping"
}
}
}
function cudaArm64Common {
param (
[string]$cudaMajorVer,
[string]$cudaExactVer,
[switch]$Optional
)
$cuda = findCudaRoot $cudaMajorVer $cudaExactVer
if ($cuda) {
$arm64DistDir = "${script:SRC_DIR}\dist\windows-arm64"
mkdir -Force -path "${arm64DistDir}\lib\ollama\" | Out-Null
$unavailableReason = cudaArm64UnavailableReason $cuda
if ($Optional -and $unavailableReason) {
Write-Output "CUDA v$cudaMajorVer Windows ARM64 toolchain not detected ($unavailableReason), skipping"
return
}
Write-Output "Building llama-server CUDA v$cudaMajorVer backend for Windows ARM64 $cuda"
$oldEnvironment = saveEnvironment
$oldGenerator = $env:CMAKE_GENERATOR
try {
$env:CMAKE_GENERATOR = "Ninja"
if ($Optional -and -not (ensureMsvcForNinja "arm64" -Optional)) {
Write-Output "CUDA v$cudaMajorVer Windows ARM64 toolchain not detected, skipping"
return
}
if (-not $Optional) {
ensureMsvcForNinja "arm64"
}
$cudaArgs = cudaArm64CMakeArgs $cuda
$architectureArgs = cudaArm64ArchitectureArgs
$configureArgs = @(
"-S", "llama\server",
"--preset", "llama_cuda_v$($cudaMajorVer)_windows_arm64",
"-G", "Ninja",
"--install-prefix", "$arm64DistDir"
) + $cudaArgs + $architectureArgs
& cmake @configureArgs
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
& cmake --build "build\llama-server-cuda_v$($cudaMajorVer)_arm64" --target ggml-cuda --config Release --parallel $script:JOBS
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
& cmake --install "build\llama-server-cuda_v$($cudaMajorVer)_arm64" --component llama-server --strip
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
} finally {
restoreEnvironment $oldEnvironment
$env:CMAKE_GENERATOR = $oldGenerator
}
} else {
if ($cudaExactVer) {
Write-Output "CUDA v$cudaExactVer not detected, skipping Windows ARM64 build"
} else {
Write-Output "CUDA v$cudaMajorVer not detected, skipping Windows ARM64 build"
}
}
}
function cuda12 {
if ($script:ARCH -eq "arm64") {
Write-Output "CUDA v12 is not supported on ARM64, skipping"
return
}
cudaCommon("12")
}
function cuda13 {
cudaCommon("13")
if ($script:ARCH -eq "arm64") {
cudaArm64Common "13" "13.4"
return
}
cudaCommon "13" "13.0"
}
function cuda13Arm64 {
cudaArm64Common "13" "13.4"
}
function cuda13Arm64Cross {
cuda13Arm64
}
function cuda13Arm64IfAvailable {
cudaArm64Common "13" "13.4" -Optional
}
function rocm6 {
@@ -513,15 +794,9 @@ function mlxCuda13 {
mkdir -Force -path "${script:DIST_DIR}\" | Out-Null
$cudaMajorVer="13"
if ($script:ARCH -ne "arm64") {
if ("$script:CUDA_DIRS".Contains("v$cudaMajorVer")) {
foreach ($d in $Script:CUDA_DIRS){
if ($d.FullName.Contains("v$cudaMajorVer")) {
if (test-path -literalpath (join-path -path $d -childpath "nvcc.exe" ) ) {
$cuda=($d.FullName|split-path -parent)
break
}
}
}
$cudaExactVer = if ($env:OLLAMA_MLX_CUDA_VERSION) { $env:OLLAMA_MLX_CUDA_VERSION } else { "$cudaMajorVer.0" }
$cuda = findCudaRoot $cudaMajorVer $cudaExactVer
if ($cuda) {
# Check for cuDNN - required for MLX CUDA backend
# Supports two layouts:
@@ -554,23 +829,34 @@ function mlxCuda13 {
}
Write-Output "Building MLX CUDA v$cudaMajorVer backend libraries $cuda"
$oldCudaPath = $env:CUDA_PATH
$oldCudaToolkitRoot = $env:CUDAToolkit_ROOT
$oldCudaCxx = $env:CUDACXX
$env:CUDA_PATH=$cuda
$env:CUDAToolkit_ROOT=$cuda
$cudaFlags = @()
if ($env:OLLAMA_CMAKE_CUDA_FLAGS) {
$cudaFlags += "-DCMAKE_CUDA_FLAGS=$env:OLLAMA_CMAKE_CUDA_FLAGS"
try {
$cudaFlags = @()
if ($env:OLLAMA_CMAKE_CUDA_FLAGS) {
$cudaFlags += "-DCMAKE_CUDA_FLAGS=$env:OLLAMA_CMAKE_CUDA_FLAGS"
}
$cudaToolsetArgs = cudaCMakeArgs $cuda
$configureArgs = @("-S", ".", "-B", "build\mlx_cuda_v$cudaMajorVer", "-DOLLAMA_MLX_BACKENDS=cuda_v$cudaMajorVer") + $cudaToolsetArgs + $cudaFlags + @("-DOLLAMA_PAYLOAD_INSTALL_PREFIX=$script:DIST_DIR", "--install-prefix", "$script:DIST_DIR")
& cmake @configureArgs
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
$buildArgs = @("--build", "build\mlx_cuda_v$cudaMajorVer", "--target", "ollama-mlx-cuda_v$cudaMajorVer", "--config", "Release", "--parallel", "$script:JOBS")
if ($env:CMAKE_GENERATOR -notlike "Ninja*") {
$buildArgs += @("--", "/nodeReuse:false")
}
& cmake @buildArgs
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
} finally {
$env:CUDA_PATH=$oldCudaPath
$env:CUDAToolkit_ROOT=$oldCudaToolkitRoot
$env:CUDACXX=$oldCudaCxx
}
$cudaToolsetArgs = cudaCMakeArgs $cuda
$configureArgs = @("-S", ".", "-B", "build\mlx_cuda_v$cudaMajorVer", "-DOLLAMA_MLX_BACKENDS=cuda_v$cudaMajorVer") + $cudaToolsetArgs + $cudaFlags + @("-DOLLAMA_PAYLOAD_INSTALL_PREFIX=$script:DIST_DIR", "--install-prefix", "$script:DIST_DIR")
& cmake @configureArgs
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
$buildArgs = @("--build", "build\mlx_cuda_v$cudaMajorVer", "--target", "ollama-mlx-cuda_v$cudaMajorVer", "--config", "Release", "--parallel", "$script:JOBS")
if ($env:CMAKE_GENERATOR -notlike "Ninja*") {
$buildArgs += @("--", "/nodeReuse:false")
}
& cmake @buildArgs
if ($LASTEXITCODE -ne 0) { exit($LASTEXITCODE)}
} else {
Write-Output "CUDA v$cudaMajorVer not detected, skipping MLX build"
Write-Output "CUDA v$cudaExactVer not detected - set OLLAMA_MLX_CUDA_VERSION to use a different CUDA v$cudaMajorVer toolkit"
Write-Output "Skipping MLX build"
}
}
}
@@ -867,6 +1153,57 @@ function newDependencyAuditJob($payloadDir, $label, $reportPath, $dependencyDirs
} -ArgumentList $payloadDir, $label, $reportPath, $dumpbin, $dependencyDirText
}
function verifyWindowsArm64Binaries {
param (
[string]$payloadDir = "${script:SRC_DIR}\dist\windows-arm64"
)
$dumpbin = findDumpbin
if (-not $dumpbin) {
Write-Error "Unable to locate dumpbin.exe for Windows ARM64 binary verification"
exit(1)
}
if (-not (Test-Path -Path $payloadDir)) {
Write-Error "Windows ARM64 payload directory not found: $payloadDir"
exit(1)
}
$binaries = Get-ChildItem -Path $payloadDir -Recurse -File -Include *.dll,*.exe | Sort-Object FullName
if (-not $binaries) {
Write-Error "No Windows binaries found under $payloadDir"
exit(1)
}
$bad = [System.Collections.Generic.List[string]]::new()
$arm64xCount = 0
foreach ($binary in $binaries) {
$output = & $dumpbin /nologo /headers $binary.FullName 2>&1
if ($LASTEXITCODE -ne 0) {
$bad.Add("$($binary.FullName): dumpbin failed with exit code $LASTEXITCODE")
continue
}
$machineLine = $output | Where-Object { $_ -match '^\s*[0-9A-Fa-f]+\s+machine\s+\(' } | Select-Object -First 1
if (-not $machineLine) {
$bad.Add("$($binary.FullName): unable to determine PE machine type")
continue
}
if ($machineLine -match '\(ARM64X\)') {
$arm64xCount++
continue
}
if ($machineLine -notmatch '^\s*AA64\s+machine\s+\(ARM64\)') {
$bad.Add("$($binary.FullName): $machineLine")
}
}
if ($bad.Count -gt 0) {
Write-Error "Windows ARM64 binary verification failed:`n$([string]::Join([Environment]::NewLine, $bad))"
exit(1)
}
Write-Output "Verified $($binaries.Count) Windows ARM64/ARM64X binaries under $payloadDir ($arm64xCount ARM64X)"
}
function stageComponents($mainDir, $stagingDir, $pattern, $readmePrefix) {
$components = Get-ChildItem -Path "${mainDir}\lib\ollama" -Directory -Filter $pattern -ErrorAction SilentlyContinue
if ($components) {
@@ -924,6 +1261,7 @@ function zip {
$arm64Dir = "${distDir}\windows-arm64"
if (Test-Path -Path $arm64Dir) {
if ((Test-Path -Path "${arm64Dir}\ollama.exe") -and (Test-Path -Path "${arm64Dir}\lib\ollama\llama-server.exe")) {
verifyWindowsArm64Binaries $arm64Dir
Write-Output "Generating ${distDir}\ollama-windows-arm64.zip"
$jobs += newZipJob $arm64Dir "${distDir}\ollama-windows-arm64.zip"
$jobs += newDependencyAuditJob $arm64Dir "windows-arm64" "${distDir}\dependency-audit-windows-arm64.txt"
@@ -962,15 +1300,18 @@ checkEnv
try {
if ($($args.count) -eq 0) {
cpu
cpuArm64
cuda12
cuda13
if ($script:ARCH -ne "arm64") {
cuda13Arm64IfAvailable
}
rocm7
vulkan
mlxCuda13
ollama
app
cpuArm64
ollamaArm64
app
appArm64
deps
sign
+14 -6
View File
@@ -102,6 +102,8 @@ const (
maxDownloadPartSize int64 = 1000 * format.MegaByte
)
var downloadStallTimeout = 30 * time.Second
func (p *blobDownloadPart) Name() string {
return strings.Join([]string{
p.blobDownload.Name, "partial", strconv.Itoa(p.N),
@@ -330,7 +332,11 @@ func (b *blobDownload) run(ctx context.Context, requestURL *url.URL, opts *regis
func (b *blobDownload) downloadChunk(ctx context.Context, requestURL *url.URL, w io.Writer, part *blobDownloadPart) error {
g, ctx := errgroup.WithContext(ctx)
attemptStarted := time.Now()
transferDone := make(chan struct{})
g.Go(func() error {
defer close(transferDone)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil)
if err != nil {
return err
@@ -359,19 +365,21 @@ func (b *blobDownload) downloadChunk(ctx context.Context, requestURL *url.URL, w
})
g.Go(func() error {
ticker := time.NewTicker(time.Second)
ticker := time.NewTicker(min(time.Second, downloadStallTimeout/2))
defer ticker.Stop()
for {
select {
case <-transferDone:
return nil
case <-ticker.C:
if part.Completed.Load() >= part.Size {
return nil
}
part.lastUpdatedMu.Lock()
lastUpdated := part.lastUpdated
part.lastUpdatedMu.Unlock()
if lastUpdated.Before(attemptStarted) {
lastUpdated = attemptStarted
}
if !lastUpdated.IsZero() && time.Since(lastUpdated) > 30*time.Second {
if time.Since(lastUpdated) > downloadStallTimeout {
const msg = "%s part %d stalled; retrying. If this persists, press ctrl-c to exit, then 'ollama pull' to find a faster connection."
slog.Info(fmt.Sprintf(msg, b.Digest[7:19], part.N))
// reset last updated
+113
View File
@@ -0,0 +1,113 @@
package server
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"testing"
"time"
)
func BenchmarkDownloadChunkCompletion(b *testing.B) {
data := make([]byte, 1024*1024)
digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", fmt.Sprint(len(data)))
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write(data)
}))
b.Cleanup(server.Close)
requestURL, err := url.Parse(server.URL)
if err != nil {
b.Fatal(err)
}
downloadPath := filepath.Join(b.TempDir(), "blob")
b.SetBytes(int64(len(data)))
b.ReportAllocs()
b.ResetTimer()
for range b.N {
download := &blobDownload{Name: downloadPath, Digest: digest}
part := &blobDownloadPart{Size: int64(len(data)), blobDownload: download}
if err := download.downloadChunk(b.Context(), requestURL, io.Discard, part); err != nil {
b.Fatal(err)
}
}
}
func TestDownloadChunkReturnsWhenTransferCompletes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1")
w.WriteHeader(http.StatusPartialContent)
_, _ = w.Write([]byte{0})
}))
t.Cleanup(server.Close)
requestURL, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
download := &blobDownload{
Name: filepath.Join(t.TempDir(), "blob"),
Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000",
}
part := &blobDownloadPart{Size: 1, blobDownload: download}
ctx, cancel := context.WithTimeout(t.Context(), 250*time.Millisecond)
defer cancel()
if err := download.downloadChunk(ctx, requestURL, io.Discard, part); err != nil {
t.Fatalf("downloadChunk() error = %v, want nil", err)
}
}
func TestDownloadChunkDetectsStallBeforeFirstByte(t *testing.T) {
requestStarted := make(chan struct{})
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
close(requestStarted)
w.Header().Set("Content-Length", "1")
w.WriteHeader(http.StatusPartialContent)
w.(http.Flusher).Flush()
<-r.Context().Done()
}))
t.Cleanup(server.Close)
requestURL, err := url.Parse(server.URL)
if err != nil {
t.Fatal(err)
}
download := &blobDownload{Digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000"}
part := &blobDownloadPart{Size: 1, blobDownload: download}
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
originalStallTimeout := downloadStallTimeout
downloadStallTimeout = 50 * time.Millisecond
t.Cleanup(func() {
downloadStallTimeout = originalStallTimeout
})
started := time.Now()
err = download.downloadChunk(ctx, requestURL, io.Discard, part)
elapsed := time.Since(started)
select {
case <-requestStarted:
default:
t.Fatal("download request did not start")
}
if !errors.Is(err, errPartStalled) {
t.Fatalf("downloadChunk() error = %v after %v, want %v", err, elapsed, errPartStalled)
}
if elapsed >= 5*downloadStallTimeout {
t.Fatalf("downloadChunk() detected the stall after %v, want less than %v", elapsed, 5*downloadStallTimeout)
}
}