Compare commits

...
Author SHA1 Message Date
Jeffrey Morgan 87fb711e0c model: fix poolside parser/renderer 2026-05-30 19:34:11 -07:00
Daniel Hiltgen 11be8f6ac8 mlx: fix dev mode search path (#16355)
The superbuild from the llama-server work changed paths but missed updating the MLX library resolution code to match.
2026-05-29 16:33:40 -07:00
Daniel Hiltgenandjmorganca 9db4bdbad6 runner: Remove CGO engines, use llama-server exclusively for GGML models (#16031)
* broad lint fixes to sidestep CI scope glitch

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

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

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

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

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

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

* refine implementation

* ci: fix windows MLX build

* ci: fix windows llama-server build

* ci: fix windows rocm build

* ci: windows mlx tuning

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

* ci: fix windows dependencies

* win: fix dependency gathering

* disable openmp

* win: arm64 cross-compile build

also DRY out CI steps

* scheduler improvements

* ci: improvements from #15982

* win: favor ninja for faster developer builds

* win: fix build

* win: fix arm64 cross-compile

* win: avoid spaces in compiler path

* misc discovery fixes, and bos handling

* lint fixes

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

* llama.cpp update

* win: handle multiple CRT dirs

* vulkan: add windows iGPU detection

* fix creation bugs for patched models, other refactoring work

* tune batch size for better performance

* ci and lint fixes

* fix repeat_last_n bug

* build: revamp build for better developer UX

* amd, sampler, qwen3next fixes

* version bump

* fix mlx build

* revamp GPU discovery

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

* version bump

* missing file

* ci: fix cache miss on rocm build

* refine vulkan dep handling

* fix ps reporting bug on full GPU load

* improve cmake wiring for customized local builds

* version bump

* docker build arg cleanup

* improve windows exit error logs

* fix community gemma4 support and ci flakes

* fix mlx unit test

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

* version bump

* fix ps view for full gpu layer offload

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

* pick best template by capabilities

* version bump

* ci: harden apt repos

* remove unused cpu core discovery

* adjust batch default logic to reduce OOMs

* support larger tool calls

* fix audio support, template show

* qwen35 mtp patch support

* flesh out dtypes

* rocm deps

* version bump

* lint fix

* block broken gfx1150 on windows

* fix qwen3.5 moe mtp tensors in patch

* mmproj oom fallback and vulkan on by default

* qwen MTP compat fix

* version bump

* ci: fix WoA cross-compile

* ci: workaround ui tool in cross-compile

* version bump

* win: enable OpenMP for CPU builds

* build: improve developer UX

* ci: windows path workaround for CPU build

* win: fix WoA dependencies

* win: fix large offset reads for mmproj patched loads

* version bump

* fix vulkan dup detection

* add OLLAMA_IGPU_ENABLE and largely disable iGPUs by default

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

* fix unit test scheduler interaction hang

* fix multi-gpu filtering

* version bump

* review comments

* fix thinking level

* fix linux rocm ordering and granite 3.3 template

* version bump

* ci fix - non-shallow MLX checkout

* bypass linux sysfs unit test on windows

---------

Co-authored-by: jmorganca <jmorganca@gmail.com>
2026-05-29 13:35:47 -07:00
Patrick Devine f63eea3d27 mlx: fix reported information in ollama show (#16289)
This change updates the show API for MLX models to:
  * display the correct quantization in mixed precision models
  * not display the global_scale scalar value
  * not duplicate the `tools` capability
2026-05-24 14:08:06 -07:00
Anto jones 632ff00798 server: remove duplicate template parsing (#16287) 2026-05-24 13:27:24 -07:00
Jesse Gross 275f122cda mlxrunner: keep gated-delta recurrent state in float32
Split the gated-delta Metal/CUDA kernels' dtype template into separate
input (InT) and state (StT) types so activations can stay in bf16/fp16
while the accumulated delta state stays in float32. Allocate the delta
state and qwen3_5's no-cache zero state in float32 to match.
2026-05-22 09:32:09 -07:00
Jesse Gross 32568531bd create: read draft architecture from its config.json
Previously the draft architecture was hardcoded to
Gemma4AssistantForCausalLM. Read it from the draft model's config so
any draft architecture can be packaged.
2026-05-22 09:32:09 -07:00
Jesse Gross 438fb991e4 mlxrunner: move YaRN RoPE helpers into x/models/nn
Move RopeParameters, BuildYarnRopeFreqs, and ScaleRotaryPart out of
laguna and into x/models/nn so other models can reuse them.
2026-05-22 09:32:09 -07:00
Jesse Gross 358af4af23 Revert "mlxrunner: add DFlash speculative decoding (#16134)"
This reverts commit 98e26b8c37.

The DFlash integration is too invasive to keep at this stage: it
threads DFlash-specific logic through the pipeline, base model
interfaces, and the cache layer. The recurrent cache also now
has qwen3.5 model-specific code. Revert it now and reintroduce
the self-contained, generally-useful pieces (YaRN RoPE DRY-out, draft
architecture autodetection, gated-delta fp32 state) as separate
follow-up commits.
2026-05-22 09:32:09 -07:00
Parth Sareen 91c8e5e1a8 launch: enriched model inventory (#16230) 2026-05-21 11:57:20 -07:00
Daniel Hiltgen 4b2d529966 Reduce startup model hydration (#16215)
* Reduce startup model hydration

Add a lightweight model list cache for tags and launch inventory, while keeping show cache population lazy. This avoids loading every local model at startup on large model stores.

* harden flaky scheduler unit test

* remove extra launch model metadata text

* review comments

* review comments
2026-05-19 15:53:08 -07:00
Bruce MacDonald e6b1d751f2 codex: omit patch tool type (#16231)
Including this value can cause schema compatibility issues. It was removed from codex in a new version.
2026-05-19 13:37:05 -07:00
Eva HandBruce MacDonald 56b319f457 launch: add codex model metadata catalog (#15795)
Co-authored-by: Bruce MacDonald <brucewmacdonald@gmail.com>
2026-05-18 15:26:43 -07:00
Daniel Hiltgen 42e6f56c2a ci: speed up release builds (#15982)
* ci: speed up release builds

This should help speed things up for release.  It also will help
speed up local developer builds a little.

* ci: dedup linux build steps and optimize

* review comments
2026-05-15 14:53:15 -07:00
Daniel Hiltgen da679adcde quiet down kv log spew (#16105) 2026-05-15 13:28:32 -07:00
Parth Sareen b9c0421f03 docs: add codex app (#16163) 2026-05-14 17:53:15 -07:00
Patrick Devine 98e26b8c37 mlxrunner: add DFlash speculative decoding (#16134)
This change adds dflash block diffusion speculative decoding to the MLX runner. Included in this change:

support for qwen3.6 moe/dense speculative decoding
draft model recurrent cache playback
RoPE/YaRN changes (DRY out the laguna/dflash MoE YaRN implementation)
support for greedy sampling / leviathan/chen sampling
2026-05-14 14:02:34 -07:00
Parth Sareen c28ddc0a7b launch: codex app restarts (#16155) 2026-05-14 12:08:13 -07:00
Parth Sareen 3ad2fa3fb5 launch: update codex app UI copy (#16157) 2026-05-14 12:08:08 -07:00
Parth Sareen 6b6f45ef0e docs: hide codex app till launch (#16153) 2026-05-14 10:56:52 -07:00
Patrick Devine 4860130f83 mlx: rework the MLX sampler (#16122)
* mlx: rework the MLX sampler

Replace the MLX sampler transform chain with an explicit distribution pipeline that applies:
  1. penalties
  2. top-k
  3. temperature/softmax
  4. top-p
  5. min-p
  6. normalize
  7. categorical

The common top_k path now keeps sparse [B,K] token ids/probabilities on GPU instead of carrying full-vocab
scores, and sampled MTP reuses those draft/target distributions for acceptance, bonus, and residual sampling.

This change also fixes the seed parameter so that temperature sampling and sampled MTP are reproducible.
2026-05-13 17:18:27 -07:00
Parth Sareen ac7295ccab launch: codex app integration (#16120) 2026-05-13 17:11:52 -07:00
Daniel Hiltgen 6398cd5b78 mlx: add memory trace logging (#16131)
This should help narrow down the root cause of #16030
2026-05-13 13:37:31 -07:00
Eva H 3af1a008e2 launch/opencode: add image modalities for vision models (#15922) 2026-05-12 15:51:46 -04:00
Eva H 6bdb73073b anthropic: Preserve Claude local image-path tool results in renderer-owned prompt formatting (#16047) 2026-05-12 00:02:17 -04:00
Daniel Hiltgen 421faa0263 mlx: fix macOS 26 target leakage in v3 metallib (#16053)
MLX compiles the AIR objects with the requested -mmacosx-version-min, but its final metallib step invokes metal instead of metallib. With the macOS 26 SDK, that can stamp the Metal v3 library with a macOS 26 deployment target.

Relink the generated AIR files with metallib before install until this is fixed upstream.
2026-05-11 16:37:57 -07:00
Daniel Hiltgen 206b049508 mlx: avoid status timeout during inference (#16086)
The MLX runner now routes model work through a locked worker thread. Status also used that worker only to sample memory, so a scheduler health ping could sit behind long prefill or generation until its 10s context expired, causing /v1/status to return 500 and the server to treat the runner as unhealthy.

While Metal doesn't change VRAM reporting, CUDA does. Cache the last memory sample and make status perform only a short best-effort refresh. If the worker is busy, status returns the cached value while a single background refresh continues and updates the cache when the worker becomes available. The in-flight guard and lifecycle context keep this from spawning unbounded refreshes while preserving live VRAM refresh behavior for CUDA.

Fixes #16081
2026-05-11 16:03:38 -07:00
Patrick Devine d819ef0f97 mlx: update the imagegen runner for mlx thread affinity (#16096) 2026-05-11 13:05:06 -07:00
Daniel Hiltgen 3d5a011a2e app: harden update flows (#16100)
* app: harden update flows

This hardens the windows update flows and adds a new opt-in and CI triggered unit test to verify Mac/Windows updates with verification.

* test: harden unit tests for OLLAMA_MODELS being set

* app: harden updater
2026-05-11 12:24:01 -07:00
Daniel Hiltgen c2f2d90a67 test: integration test hardening (#13532)
* test: integration test hardening

Improve reliability on slower systems, and some flakes.  Fix
a few logic flaws on the newer tests, general hardening.

* tighten up vision logging

* add new models

* remove some older models - still covered by library scenarios
2026-05-08 15:54:17 -07:00
Daniel Hiltgen 1e1b34dada mlx: refined model push behavior (#15431)
* mlx: refined model push behavior

Refine the algorithm for parallel push of safetensors based models to get
better reliability and throughput.

* review comments, hardening, and performance tuning for slow links

* review comments
2026-05-08 14:25:30 -07:00
1202 changed files with 40047 additions and 432034 deletions

No files matched your search

+261 -106
View File
@@ -16,7 +16,7 @@ jobs:
outputs:
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
VERSION: ${{ steps.goflags.outputs.VERSION }}
vendorsha: ${{ steps.changes.outputs.vendorsha }}
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
steps:
- uses: actions/checkout@v4
- name: Set environment
@@ -24,7 +24,7 @@ jobs:
run: |
echo GOFLAGS="'-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${GITHUB_REF_NAME#v}\" \"-X=github.com/ollama/ollama/server.mode=release\"'" | tee -a $GITHUB_OUTPUT
echo VERSION="${GITHUB_REF_NAME#v}" | tee -a $GITHUB_OUTPUT
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
echo vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
darwin-build:
runs-on: macos-26-xlarge
@@ -57,7 +57,9 @@ jobs:
go-version-file: go.mod
cache-dependency-path: |
go.sum
Makefile.sync
LLAMA_CPP_VERSION
MLX_VERSION
MLX_C_VERSION
- run: |
./scripts/build_darwin.sh
- name: Log build results
@@ -73,15 +75,18 @@ jobs:
dist/*.dmg
windows-depends:
needs: setup-environment
strategy:
matrix:
os: [windows]
arch: [amd64]
preset: ['CPU']
build-steps: ['cpu cpuArm64']
include:
- os: windows
arch: amd64
preset: 'CUDA 12'
build-steps: cuda12
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
cuda-components:
- '"cudart"'
@@ -89,10 +94,10 @@ jobs:
- '"cublas"'
- '"cublas_dev"'
cuda-version: '12.8'
flags: ''
- os: windows
arch: amd64
preset: 'CUDA 13'
build-steps: cuda13
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
cuda-components:
- '"cudart"'
@@ -103,23 +108,23 @@ jobs:
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.0'
flags: ''
- os: windows
arch: amd64
preset: 'ROCm 6'
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
rocm-version: '6.2'
flags: '-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
runner_dir: 'rocm'
preset: 'ROCm 7'
build-steps: rocm7
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
rocm-version: '7.1'
- os: windows
arch: amd64
preset: Vulkan
build-steps: vulkan
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
flags: ''
runner_dir: 'vulkan'
- os: windows
arch: amd64
preset: 'MLX CUDA 13'
build-steps: mlxCuda13
build-parallel: '16'
cmake-cuda-flags: '-t 6'
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
cuda-components:
@@ -135,18 +140,34 @@ jobs:
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.0'
flags: ''
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
environment: release
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
steps:
- if: startsWith(matrix.preset, 'MLX ')
name: Increase pagefile to 200 GB
uses: al-cheb/configure-pagefile-action@v1.5
with:
minimum-size: 16GB
maximum-size: 200GB
disk-root: "D:"
- name: Install system dependencies
run: |
choco install -y --no-progress ccache ninja
if (Get-Command ccache -ErrorAction SilentlyContinue) {
ccache -o cache_dir=${{ github.workspace }}\.ccache
}
- if: matrix.preset == 'CPU'
name: Install Windows ARM64 cross compiler
run: |
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
}
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan') || startsWith(matrix.preset, 'MLX ')
id: cache-install
uses: actions/cache/restore@v4
@@ -195,12 +216,12 @@ jobs:
}
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
if (Test-Path $vulkanRuntime) {
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
}
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
- if: matrix.preset == 'CPU'
run: |
echo "CC=clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
echo "CXX=clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
- if: startsWith(matrix.preset, 'MLX ')
name: Install cuDNN for MLX
run: |
@@ -232,72 +253,63 @@ jobs:
with:
path: ${{ github.workspace }}\.ccache
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
- name: Build target "${{ matrix.preset }}"
- name: Build Windows dependencies
run: |
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }} --install-prefix "$((pwd).Path)\dist\${{ matrix.os }}-${{ matrix.arch }}"
cmake --build --parallel ([Environment]::ProcessorCount) --preset "${{ matrix.preset }}"
cmake --install build --component "${{ startsWith(matrix.preset, 'MLX ') && 'MLX' || startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || startsWith(matrix.preset, 'Vulkan') && 'Vulkan' || 'CPU' }}" --strip
Remove-Item -Path dist\lib\ollama\rocm\rocblas\library\*gfx906* -ErrorAction SilentlyContinue
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
./scripts/build_windows.ps1 @steps
env:
CMAKE_GENERATOR: Ninja
OLLAMA_BUILD_PARALLEL: ${{ matrix.build-parallel || '' }}
OLLAMA_CMAKE_CUDA_FLAGS: ${{ matrix.cmake-cuda-flags || '' }}
- name: Log build results
run: |
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
- if: matrix.preset == 'CPU'
name: Verify Windows CPU payloads
shell: bash
run: |
set -euo pipefail
for payload in \
dist/windows-amd64/lib/ollama/llama-server.exe \
dist/windows-arm64/lib/ollama/llama-server.exe
do
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
done
- uses: actions/upload-artifact@v4
with:
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
path: dist\*
windows-build:
strategy:
matrix:
os: [windows]
arch: [amd64, arm64]
include:
- os: windows
arch: amd64
llvmarch: x86_64
- os: windows
arch: arm64
llvmarch: aarch64
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
runs-on: windows
environment: release
needs: [setup-environment]
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
steps:
- name: Install ARM64 system dependencies
if: matrix.arch == 'arm64'
run: |
$ErrorActionPreference = "Stop"
Set-ExecutionPolicy Bypass -Scope Process -Force
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
echo "C:\ProgramData\chocolatey\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
Invoke-WebRequest -Uri https://aka.ms/vs/17/release/vc_redist.arm64.exe -OutFile "${{ runner.temp }}\vc_redist.arm64.exe"
Start-Process -FilePath "${{ runner.temp }}\vc_redist.arm64.exe" -ArgumentList @("/install", "/quiet", "/norestart") -NoNewWindow -Wait
choco install -y --no-progress git gzip
echo "C:\Program Files\Git\cmd" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Install clang and gcc-compat
run: |
$ErrorActionPreference = "Stop"
Set-ExecutionPolicy Bypass -Scope Process -Force
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-${{ matrix.llvmarch }}.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt*").path
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
}
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: |
go.sum
Makefile.sync
LLAMA_CPP_VERSION
MLX_VERSION
MLX_C_VERSION
- name: Verify gcc is actually clang
run: |
$ErrorActionPreference='Continue'
@@ -314,20 +326,30 @@ jobs:
with:
node-version: "20"
- run: |
./scripts/build_windows ollama app
./scripts/build_windows ollama ollamaArm64 app appArm64
- name: Verify Windows build payloads
shell: bash
run: |
set -euo pipefail
for payload in \
dist/windows-amd64/ollama.exe \
dist/windows-arm64/ollama.exe
do
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
done
- name: Log build results
run: |
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
- uses: actions/upload-artifact@v4
with:
name: build-${{ matrix.os }}-${{ matrix.arch }}
name: build-windows-amd64
path: |
dist\*
windows-app:
runs-on: windows
environment: release
needs: [windows-build, windows-depends]
needs: [setup-environment, windows-build, windows-depends]
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
@@ -353,7 +375,9 @@ jobs:
go-version-file: go.mod
cache-dependency-path: |
go.sum
Makefile.sync
LLAMA_CPP_VERSION
MLX_VERSION
MLX_C_VERSION
- uses: actions/download-artifact@v4
with:
pattern: depends-windows*
@@ -367,6 +391,18 @@ jobs:
- name: Log dist contents after download
run: |
gci -path .\dist -recurse
- name: Verify Windows package inputs
shell: bash
run: |
set -euo pipefail
for payload in \
dist/windows-amd64/ollama.exe \
dist/windows-amd64/lib/ollama/llama-server.exe \
dist/windows-arm64/ollama.exe \
dist/windows-arm64/lib/ollama/llama-server.exe
do
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
done
- run: |
./scripts/build_windows.ps1 deps sign installer zip
- name: Log contents after build
@@ -380,20 +416,33 @@ jobs:
dist/*.ps1
dist/OllamaSetup.exe
linux-build:
linux-depends:
strategy:
matrix:
include:
- os: linux
arch: amd64
target: archive
- os: linux
arch: amd64
target: rocm
- os: linux
arch: arm64
target: archive
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
- arch: amd64
target: llama-server-cpu
- arch: amd64
target: llama-server-cuda_v12
- arch: amd64
target: llama-server-cuda_v13
- arch: amd64
target: mlx
- arch: amd64
target: llama-server-rocm_v7_2
- arch: amd64
target: llama-server-vulkan
- arch: arm64
target: llama-server-cpu
- arch: arm64
target: llama-server-cuda_v12
- arch: arm64
target: llama-server-cuda_v13
- arch: arm64
target: jetpack-5
- arch: arm64
target: jetpack-6
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
environment: release
needs: setup-environment
env:
@@ -401,83 +450,114 @@ jobs:
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- if: matrix.target == 'mlx'
name: Increase Linux swap to 200 GB
shell: bash
run: |
set -e
SWAP_PATH=/swapfile-mlx
SWAP_SIZE_GB=200
if [ -f "$SWAP_PATH" ]; then
sudo swapoff "$SWAP_PATH" 2>/dev/null || true
sudo rm -f "$SWAP_PATH"
fi
if ! sudo fallocate -l ${SWAP_SIZE_GB}G "$SWAP_PATH" 2>/dev/null; then
echo "fallocate unsupported, falling back to dd"
sudo dd if=/dev/zero of="$SWAP_PATH" bs=1M count=$((SWAP_SIZE_GB * 1024))
fi
sudo chmod 600 "$SWAP_PATH"
sudo mkswap "$SWAP_PATH"
sudo swapon "$SWAP_PATH"
swapon --show
free -h
- uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.os }}/${{ matrix.arch }}
platforms: linux/${{ matrix.arch }}
target: ${{ matrix.target }}
provenance: false
sbom: false
build-args: |
GOFLAGS=${{ env.GOFLAGS }}
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
cache-to: type=inline
- name: Deduplicate CUDA libraries
run: |
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
- run: |
for COMPONENT in bin/* lib/ollama/*; do
case "$COMPONENT" in
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
esac
done
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
- run: |
echo "Manifests"
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
echo $ARCHIVE
cat $ARCHIVE
done
- run: |
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd --ultra -22 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst);
done
- uses: actions/upload-artifact@v4
with:
name: bundles-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.target }}
path: |
*.tar.zst
OLLAMA_MLX_BUILD_JOBS=16
OLLAMA_MLX_NVCC_THREADS=6
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
cache-from: |
type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }}
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
cache-to: type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }},mode=max
# Build each Docker variant (OS, arch, and flavor) separately. Using QEMU is unreliable and slower.
# Heavy stages were pre-built by linux-depends; this job is cache-hit-only for those layers
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
docker-build-push:
strategy:
matrix:
include:
- os: linux
arch: arm64
archive-target: archive
build-args: |
CGO_CFLAGS
CGO_CXXFLAGS
GOFLAGS
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
OLLAMA_MLX_BUILD_JOBS=16
OLLAMA_MLX_NVCC_THREADS=6
cache-from: |
type=registry,ref=ollama/release:cache-arm64-llama-server-cpu
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v12
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v13
type=registry,ref=ollama/release:cache-arm64-jetpack-5
type=registry,ref=ollama/release:cache-arm64-jetpack-6
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
- os: linux
arch: amd64
archive-target: archive
build-args: |
CGO_CFLAGS
CGO_CXXFLAGS
GOFLAGS
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
OLLAMA_MLX_BUILD_JOBS=16
OLLAMA_MLX_NVCC_THREADS=6
cache-from: |
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v12
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v13
type=registry,ref=ollama/release:cache-amd64-mlx
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
type=registry,ref=ollama/release:cache-amd64-llama-server-vulkan
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
- os: linux
arch: amd64
suffix: '-rocm'
archive-target: image-archive
build-args: |
CGO_CFLAGS
CGO_CXXFLAGS
GOFLAGS
FLAVOR=rocm
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
OLLAMA_MLX_BUILD_JOBS=16
OLLAMA_MLX_NVCC_THREADS=6
cache-from: |
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
environment: release
needs: setup-environment
needs: [setup-environment, linux-depends]
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
steps:
@@ -492,9 +572,11 @@ jobs:
with:
context: .
platforms: ${{ matrix.os }}/${{ matrix.arch }}
provenance: false
sbom: false
build-args: ${{ matrix.build-args }}
outputs: type=image,name=${{ vars.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
cache-from: ${{ matrix.cache-from }}
cache-to: type=inline
- run: |
mkdir -p ${{ matrix.os }}-${{ matrix.arch }}
@@ -505,6 +587,64 @@ jobs:
name: digest-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}
path: |
${{ runner.temp }}/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
- uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.os }}/${{ matrix.arch }}
target: ${{ matrix.archive-target }}
provenance: false
sbom: false
build-args: ${{ matrix.build-args }}
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
cache-from: ${{ matrix.cache-from }}
- name: Deduplicate CUDA libraries
run: |
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
- name: Verify Linux build payloads
shell: bash
run: |
set -euo pipefail
base="dist/${{ matrix.os }}-${{ matrix.arch }}"
for payload in \
"$base/bin/ollama" \
"$base/lib/ollama/llama-server"
do
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
done
- run: |
for COMPONENT in bin/* lib/ollama/*; do
case "$COMPONENT" in
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
lib/ollama/rocm_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
esac
done
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
- if: matrix.suffix == '-rocm'
run: rm -f dist/${{ matrix.os }}-${{ matrix.arch }}/ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in
- run: |
echo "Manifests"
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
echo $ARCHIVE
cat $ARCHIVE
done
- run: |
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd -19 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst) &
done
wait
- uses: actions/upload-artifact@v4
with:
name: bundles-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.suffix }}
path: |
*.tar.zst
# Merge Docker images for the same flavor into a single multi-arch manifest
docker-merge-push:
@@ -544,7 +684,7 @@ jobs:
release:
runs-on: ubuntu-latest
environment: release
needs: [darwin-build, windows-app, linux-build]
needs: [darwin-build, windows-app, docker-build-push]
permissions:
contents: write
env:
@@ -562,6 +702,21 @@ jobs:
- name: Copy install scripts to dist
run: |
cp scripts/install.sh dist/install.sh
- name: Verify release artifacts
run: |
required=(
dist/OllamaSetup.exe
dist/install.ps1
dist/install.sh
dist/ollama-windows-amd64.zip
dist/ollama-windows-arm64.zip
)
for payload in "${required[@]}"; do
if [ ! -f "$payload" ]; then
echo "::error::Missing expected release artifact: $payload"
exit 1
fi
done
- name: Generate checksum file
run: find . -type f -not -name 'sha256sum.txt' | xargs sha256sum | tee sha256sum.txt
working-directory: dist
+159 -37
View File
@@ -22,7 +22,8 @@ jobs:
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.changes.outputs.changed }}
vendorsha: ${{ steps.changes.outputs.vendorsha }}
app_changed: ${{ steps.changes.outputs.app_changed }}
enginehash: ${{ steps.changes.outputs.enginehash }}
steps:
- uses: actions/checkout@v4
with:
@@ -37,8 +38,42 @@ jobs:
| xargs python3 -c "import sys; from pathlib import Path; print(any(Path(x).match(glob) for x in sys.argv[1:] for glob in '$*'.split(' ')))"
}
echo changed=$(changed 'llama/llama.cpp/**/*' 'ml/backend/ggml/ggml/**/*' '.github/**/*') | tee -a $GITHUB_OUTPUT
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
echo changed=$(changed \
'CMakeLists.txt' \
'CMakePresets.json' \
'cmake/**' \
'cmake/**/*' \
'llama/server/**/*' \
'llama/compat/**/*' \
'LLAMA_CPP_VERSION' \
'MLX_VERSION' \
'MLX_C_VERSION' \
'llama/llama.cpp/**/*' \
'ml/backend/ggml/ggml/**/*' \
'x/imagegen/mlx/**' \
'x/imagegen/mlx/**/*' \
'.github/**/*') | tee -a $GITHUB_OUTPUT
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
echo enginehash=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
patches:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Verify patches apply cleanly
shell: bash
run: |
cmake -S llama/server -B "$RUNNER_TEMP/llama-server-patch-check" \
-DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=ON \
-DGGML_BACKEND_DL=ON \
-DGGML_NATIVE=OFF \
-DGGML_OPENMP=OFF \
-DGGML_CPU_ALL_VARIANTS=ON \
-DOLLAMA_RUNNER_DIR=
linux:
needs: [changes]
@@ -47,23 +82,41 @@ jobs:
matrix:
include:
- preset: CPU
superbuild_target: ollama-local
superbuild_dir: build/local-superbuild
superbuild_args: ''
expected_payload: lib/ollama/llama-server
install-go: true
- preset: CUDA
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
superbuild_target: ollama-llama-server-cuda_v13
superbuild_dir: build/local-superbuild-cuda_v13
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87'
expected_payload: lib/ollama/cuda_v13/libggml-cuda.so
- preset: ROCm
container: rocm/dev-ubuntu-22.04:7.2.1
extra-packages: rocm-libs
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
superbuild_target: ollama-llama-server-rocm_v7_2
superbuild_dir: build/local-superbuild-rocm_v7_2
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_2 -DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
expected_payload: lib/ollama/rocm_v7_2/libggml-hip.so
- preset: Vulkan
container: ubuntu:22.04
extra-packages: >
mesa-vulkan-drivers vulkan-tools
libvulkan1 libvulkan-dev
vulkan-sdk cmake ccache g++ make
vulkan-sdk spirv-headers cmake ccache g++ make
superbuild_target: ollama-llama-server-vulkan
superbuild_dir: build/local-superbuild-vulkan
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
expected_payload: lib/ollama/vulkan/libggml-vulkan.so
- preset: 'MLX CUDA 13'
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
superbuild_target: ollama-mlx-cuda_v13
superbuild_dir: build/local-superbuild-mlx-cuda_v13
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
expected_payload: lib/ollama/mlx_cuda_v13/libmlx.so
install-go: true
runs-on: linux
container: ${{ matrix.container }}
@@ -80,11 +133,9 @@ jobs:
echo "deb [signed-by=/usr/share/keyrings/lunarg-archive-keyring.gpg] https://packages.lunarg.com/vulkan/1.4.313 jammy main" | $sudo tee /etc/apt/sources.list.d/lunarg-vulkan-1.4.313-jammy.list > /dev/null
$sudo apt-get update
fi
$sudo apt-get install -y cmake ccache ${{ matrix.extra-packages }}
# MLX requires CMake 3.25+, install from official releases
if [ "${{ matrix.preset }}" = "MLX CUDA 13" ]; then
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
fi
$sudo apt-get install -y cmake ccache curl git ${{ matrix.extra-packages }}
# Use a current CMake for upstream llama.cpp and Vulkan dependency discovery.
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
# Export VULKAN_SDK if provided by LunarG package (defensive)
if [ -d "/usr/lib/x86_64-linux-gnu/vulkan" ] && [ "${{ matrix.preset }}" = "Vulkan" ]; then
echo "VULKAN_SDK=/usr" >> $GITHUB_ENV
@@ -94,17 +145,30 @@ jobs:
- if: matrix.install-go
name: Install Go
run: |
[ -n "${{ matrix.container }}" ] || sudo=sudo
GO_VERSION=$(awk '/^go / { print $2 }' go.mod)
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar xz -C /usr/local
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | $sudo tar xz -C /usr/local
echo "/usr/local/go/bin" >> $GITHUB_PATH
- uses: actions/cache@v4
with:
path: /github/home/.cache/ccache
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
- run: |
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
cmake --build --preset "${{ matrix.preset }}" --parallel
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
- name: Build native superbuild
if: matrix.superbuild_target
run: |
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
CMAKE_BUILD_PARALLEL_LEVEL=$(nproc) cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $(nproc)
test -e "${{ matrix.superbuild_dir }}/${{ matrix.expected_payload }}"
- name: Verify local superbuild install
if: matrix.superbuild_target == 'ollama-local'
run: |
./ollama --version
"${{ matrix.superbuild_dir }}/lib/ollama/llama-server" --version
test -x "${{ matrix.superbuild_dir }}/lib/ollama/llama-quantize"
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$RUNNER_TEMP/ollama-local"
"$RUNNER_TEMP/ollama-local/bin/ollama" --version
"$RUNNER_TEMP/ollama-local/lib/ollama/llama-server" --version
test -x "$RUNNER_TEMP/ollama-local/lib/ollama/llama-quantize"
windows:
needs: [changes]
if: needs.changes.outputs.changed == 'True'
@@ -112,9 +176,16 @@ jobs:
matrix:
include:
- preset: CPU
superbuild_target: ollama-local
superbuild_dir: build\local-superbuild
superbuild_args: ''
expected_payload: lib\ollama\llama-server.exe
- preset: CUDA
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
superbuild_target: ollama-llama-server-cuda_v13
superbuild_dir: build\local-superbuild-cuda_v13
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80'
expected_payload: lib\ollama\cuda_v13\ggml-cuda.dll
cuda-components:
- '"cudart"'
- '"nvcc"'
@@ -125,14 +196,26 @@ jobs:
- '"nvptxcompiler"'
cuda-version: '13.0'
- preset: ROCm
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
rocm-version: '7.1'
superbuild_target: ollama-llama-server-rocm_v7_1
superbuild_dir: build\local-superbuild-rocm_v7_1
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_1 -DAMDGPU_TARGETS=gfx1010'
expected_payload: lib\ollama\rocm_v7_1\ggml-hip.dll
- preset: Vulkan
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
superbuild_target: ollama-llama-server-vulkan
superbuild_dir: build\local-superbuild-vulkan
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
expected_payload: lib\ollama\vulkan\ggml-vulkan.dll
- preset: 'MLX CUDA 13'
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
superbuild_target: ollama-mlx-cuda_v13
superbuild_dir: build\local-superbuild-mlx-cuda_v13
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
expected_payload: lib\ollama\mlx_cuda_v13\mlx.dll
install-go: true
cuda-components:
- '"cudart"'
- '"nvcc"'
@@ -201,6 +284,10 @@ jobs:
}
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
if (Test-Path $vulkanRuntime) {
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
}
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
- if: matrix.preset == 'MLX CUDA 13'
@@ -230,18 +317,44 @@ jobs:
C:\Program Files\NVIDIA\CUDNN
key: ${{ matrix.install }}-${{ matrix.cudnn-install }}
- uses: actions/checkout@v4
- if: matrix.superbuild_target == 'ollama-local' || matrix.install-go
uses: actions/setup-go@v5
with:
go-version-file: 'go.mod'
- uses: actions/cache@v4
with:
path: ${{ github.workspace }}\.ccache
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
- run: |
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
- name: Build native superbuild
if: matrix.superbuild_target
run: |
$ErrorActionPreference = "Stop"
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
cmake --build --parallel --preset "${{ matrix.preset }}"
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
$env:CMAKE_BUILD_PARALLEL_LEVEL = [Environment]::ProcessorCount
cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $([Environment]::ProcessorCount)
if (!(Test-Path "${{ matrix.superbuild_dir }}\${{ matrix.expected_payload }}")) {
throw "missing ${{ matrix.expected_payload }}"
}
env:
CMAKE_GENERATOR: Ninja
- name: Verify local superbuild install
if: matrix.superbuild_target == 'ollama-local'
run: |
$ErrorActionPreference = "Stop"
& ".\ollama.exe" --version
& "${{ matrix.superbuild_dir }}\lib\ollama\llama-server.exe" --version
if (!(Test-Path "${{ matrix.superbuild_dir }}\lib\ollama\llama-quantize.exe")) {
throw "missing llama-quantize.exe"
}
$installPrefix = Join-Path $env:RUNNER_TEMP "ollama-local"
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$installPrefix"
& "$installPrefix\bin\ollama.exe" --version
& "$installPrefix\lib\ollama\llama-server.exe" --version
if (!(Test-Path "$installPrefix\lib\ollama\llama-quantize.exe")) {
throw "missing installed llama-quantize.exe"
}
go_mod_tidy:
runs-on: ubuntu-latest
steps:
@@ -250,6 +363,7 @@ jobs:
run: go mod tidy --diff || (echo "Please run 'go mod tidy'." && exit 1)
test:
needs: [changes]
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
@@ -263,7 +377,9 @@ jobs:
go-version-file: 'go.mod'
cache-dependency-path: |
go.sum
Makefile.sync
LLAMA_CPP_VERSION
MLX_VERSION
MLX_C_VERSION
- uses: actions/setup-node@v4
with:
node-version: '20'
@@ -277,6 +393,17 @@ jobs:
if: ${{ startsWith(matrix.os, 'ubuntu') }}
working-directory: ./app/ui/app
run: npm test
- name: Verify MLX generated files are current
if: ${{ startsWith(matrix.os, 'ubuntu') }}
run: |
cmake -S . -B build/mlx-generate -DOLLAMA_MLX_BACKENDS=cuda_v13
cmake --build build/mlx-generate --target ollama-mlx-generate-wrappers
git diff --exit-code -- \
x/imagegen/mlx/mlx.h \
x/imagegen/mlx/mlx.c \
x/mlxrunner/mlx/generated.h \
x/mlxrunner/mlx/generated.c \
x/mlxrunner/mlx/include/mlx/c
- name: Run go generate
run: go generate ./...
@@ -284,15 +411,10 @@ jobs:
if: always()
run: go test -count=1 -benchtime=1x ./...
- name: go test app with live updater tag
if: ${{ needs.changes.outputs.app_changed == 'True' && contains(fromJSON('["macos-latest","windows-latest"]'), matrix.os) }}
run: go test -count=1 -tags updater_live ./app/...
- uses: golangci/golangci-lint-action@v9
with:
only-new-issues: true
patches:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Verify patches apply cleanly and do not change files
run: |
make -f Makefile.sync clean checkout apply-patches sync
git diff --compact-summary --exit-code
+21 -327
View File
@@ -1,4 +1,4 @@
cmake_minimum_required(VERSION 3.21)
cmake_minimum_required(VERSION 3.24)
project(Ollama C CXX)
@@ -23,30 +23,23 @@ include(GNUInstallDirs)
find_package(Threads REQUIRED)
set(CMAKE_BUILD_TYPE Release)
set(BUILD_SHARED_LIBS ON)
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
# These defaults can be overridden by presets (e.g., for static macOS llama-server builds)
if(NOT DEFINED BUILD_SHARED_LIBS)
set(BUILD_SHARED_LIBS ON)
endif()
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX Requires gnu++17 extensions to compile properly
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX require gnu++17 extensions to compile properly
set(GGML_BUILD ON)
set(GGML_SHARED ON)
set(GGML_CCACHE ON)
set(GGML_BACKEND_DL ON)
set(GGML_BACKEND_SHARED ON)
set(GGML_SCHED_MAX_COPIES 4)
set(GGML_LLAMAFILE ON)
set(GGML_CUDA_PEER_MAX_BATCH_SIZE 128)
set(GGML_CUDA_GRAPHS ON)
set(GGML_CUDA_FA ON)
set(GGML_CUDA_COMPRESSION_MODE default)
if((CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES "arm64")
OR (NOT CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_SYSTEM_PROCESSOR MATCHES "arm|aarch64|ARM64|ARMv[0-9]+"))
set(GGML_CPU_ALL_VARIANTS ON)
endif()
# GGML backend for inference is provided by llama-server (built separately via
# llama/server/CMakeLists.txt using FetchContent from the pinned llama.cpp source).
# The root CMake project is the orchestration entrypoint; backend-specific
# build rules live in subprojects under cmake/.
if(APPLE)
set(CMAKE_BUILD_RPATH "@loader_path")
@@ -55,7 +48,8 @@ if(APPLE)
endif()
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
set(OLLAMA_INSTALL_DIR ${CMAKE_INSTALL_PREFIX}/lib/ollama/${OLLAMA_RUNNER_DIR})
set(OLLAMA_LIB_DIR "lib/ollama" CACHE STRING "Install destination for Ollama runtime payloads")
set(OLLAMA_INSTALL_DIR ${OLLAMA_LIB_DIR}/${OLLAMA_RUNNER_DIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
@@ -64,309 +58,9 @@ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
# Store ggml include paths for use with target_include_directories later.
# We avoid global include_directories() to prevent polluting the include path
# for other projects like MLX (whose openblas dependency has its own common.h).
set(GGML_INCLUDE_DIRS
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/include
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu/amx
)
add_compile_definitions(NDEBUG GGML_VERSION=0x0 GGML_COMMIT=0x0)
# Define GGML version variables for shared library SOVERSION
# These are required by ggml/src/CMakeLists.txt for proper library versioning
set(GGML_VERSION_MAJOR 0)
set(GGML_VERSION_MINOR 0)
set(GGML_VERSION_PATCH 0)
set(GGML_VERSION "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
set(GGML_CPU ON)
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src)
set_property(TARGET ggml PROPERTY EXCLUDE_FROM_ALL TRUE)
get_target_property(CPU_VARIANTS ggml-cpu MANUALLY_ADDED_DEPENDENCIES)
if(NOT CPU_VARIANTS)
set(CPU_VARIANTS "ggml-cpu")
endif()
# Apply ggml include directories to ggml targets only (not globally)
target_include_directories(ggml-base PRIVATE ${GGML_INCLUDE_DIRS})
foreach(variant ${CPU_VARIANTS})
if(TARGET ${variant})
target_include_directories(${variant} PRIVATE ${GGML_INCLUDE_DIRS})
endif()
endforeach()
install(TARGETS ggml-base ${CPU_VARIANTS}
RUNTIME_DEPENDENCIES
PRE_EXCLUDE_REGEXES ".*"
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
)
check_language(CUDA)
if(CMAKE_CUDA_COMPILER)
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24" AND NOT CMAKE_CUDA_ARCHITECTURES)
set(CMAKE_CUDA_ARCHITECTURES "native")
endif()
find_package(CUDAToolkit)
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cuda)
target_include_directories(ggml-cuda PRIVATE ${GGML_INCLUDE_DIRS})
install(TARGETS ggml-cuda
RUNTIME_DEPENDENCIES
DIRECTORIES ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR}
PRE_INCLUDE_REGEXES cublas cublasLt cudart
PRE_EXCLUDE_REGEXES ".*"
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
)
endif()
set(WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX "^gfx(908|90a|1200|1201):xnack[+-]$"
CACHE STRING
"Regular expression describing AMDGPU_TARGETS not supported on Windows. Override to force building these targets. Default \"^gfx(908|90a|1200|1201):xnack[+-]$\"."
)
check_language(HIP)
if(CMAKE_HIP_COMPILER)
set(HIP_PLATFORM "amd")
if(NOT AMDGPU_TARGETS)
find_package(hip REQUIRED)
list(FILTER AMDGPU_TARGETS INCLUDE REGEX "^gfx(94[012]|101[02]|1030|110[012]|120[01])$")
endif()
if(WIN32 AND WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX)
list(FILTER AMDGPU_TARGETS EXCLUDE REGEX ${WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX})
endif()
if(AMDGPU_TARGETS)
find_package(hip REQUIRED)
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-hip)
target_include_directories(ggml-hip PRIVATE ${GGML_INCLUDE_DIRS})
if (WIN32)
target_compile_definitions(ggml-hip PRIVATE GGML_CUDA_NO_PEER_COPY)
endif()
target_compile_definitions(ggml-hip PRIVATE GGML_HIP_NO_VMM)
install(TARGETS ggml-hip
RUNTIME_DEPENDENCY_SET rocm
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
)
install(RUNTIME_DEPENDENCY_SET rocm
DIRECTORIES ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR}
PRE_INCLUDE_REGEXES hipblas rocblas amdhip64 rocsolver amd_comgr hsa-runtime64 rocsparse tinfo rocprofiler-register roctx64 rocroller drm drm_amdgpu numa elf
PRE_EXCLUDE_REGEXES ".*"
POST_EXCLUDE_REGEXES "system32"
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
)
foreach(HIP_LIB_BIN_INSTALL_DIR IN ITEMS ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR})
if(EXISTS ${HIP_LIB_BIN_INSTALL_DIR}/rocblas)
install(DIRECTORY ${HIP_LIB_BIN_INSTALL_DIR}/rocblas DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP)
break()
endif()
endforeach()
endif()
endif()
if(NOT APPLE)
find_package(Vulkan)
if(Vulkan_FOUND)
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-vulkan)
target_include_directories(ggml-vulkan PRIVATE ${GGML_INCLUDE_DIRS})
install(TARGETS ggml-vulkan
RUNTIME_DEPENDENCIES
PRE_INCLUDE_REGEXES vulkan
PRE_EXCLUDE_REGEXES ".*"
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
)
endif()
endif()
option(MLX_ENGINE "Enable MLX backend" OFF)
if(MLX_ENGINE)
message(STATUS "Setting up MLX (this takes a while...)")
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/x/imagegen/mlx)
# Find CUDA toolkit if MLX is built with CUDA support
find_package(CUDAToolkit)
# Build list of directories for runtime dependency resolution
set(MLX_RUNTIME_DIRS ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR})
# Add cuDNN bin paths for DLLs (Windows MLX CUDA builds)
# CUDNN_ROOT_DIR is the standard CMake variable for cuDNN location
if(DEFINED ENV{CUDNN_ROOT_DIR})
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/)
file(GLOB CUDNN_BIN_SUBDIRS "$ENV{CUDNN_ROOT_DIR}/bin/*")
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
endif()
# Add build output directory and MLX dependency build directories
list(APPEND MLX_RUNTIME_DIRS ${OLLAMA_BUILD_DIR})
# OpenBLAS DLL location (pre-built zip extracts into openblas-src/bin/)
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
# NCCL: on Linux, if real NCCL is found, cmake bundles libnccl.so via the
# regex below. If NCCL is not found, MLX links a static stub (OBJECT lib)
# so there is no runtime dependency. This path covers the stub build dir
# for windows so we include the DLL in our dependencies.
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/distributed/nccl/nccl_stub-prefix/src/nccl_stub-build/Release)
# Base regexes for runtime dependencies (cross-platform)
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl openblas gfortran)
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer)
if(WIN32)
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
endif()
install(TARGETS mlx mlxc
RUNTIME_DEPENDENCIES
DIRECTORIES ${MLX_RUNTIME_DIRS}
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
PRE_EXCLUDE_REGEXES ".*"
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
)
if(TARGET jaccl)
install(TARGETS jaccl
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
)
endif()
# Install the Metal library for macOS arm64 (must be colocated with the binary)
# Metal backend is only built for arm64, not x86_64
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
DESTINATION ${OLLAMA_INSTALL_DIR}
COMPONENT MLX)
endif()
# Install headers for NVRTC JIT compilation at runtime.
# MLX's own install rules use the default component so they get skipped by
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
#
# Layout:
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ — CCCL headers
# ${OLLAMA_INSTALL_DIR}/include/*.h — CUDA toolkit headers
#
# MLX's jit_module.cpp resolves CCCL via
# current_binary_dir()[.parent_path()] / "include" / "cccl"
# On Linux, MLX's jit_module.cpp resolves CCCL via
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
# This will need refinement if we add multiple CUDA versions for MLX in the future.
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
COMPONENT MLX)
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/nv
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
COMPONENT MLX)
if(NOT WIN32 AND NOT APPLE)
install(CODE "
set(_link \"${CMAKE_INSTALL_PREFIX}/lib/ollama/include\")
set(_target \"${OLLAMA_RUNNER_DIR}/include\")
if(NOT EXISTS \${_link})
execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink \${_target} \${_link})
endif()
" COMPONENT MLX)
endif()
endif()
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
# $CUDA_PATH/include/*.h via NVRTC --include-path.
if(CUDAToolkit_FOUND)
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
# (e.g. ".../include;.../include/cccl"). Find the entry that
# contains the CUDA runtime headers we need.
set(_cuda_inc "")
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
if(EXISTS "${_dir}/cuda_runtime_api.h")
set(_cuda_inc "${_dir}")
break()
endif()
endforeach()
if(NOT _cuda_inc)
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
else()
set(_dst "${OLLAMA_INSTALL_DIR}/include")
set(_MLX_JIT_CUDA_HEADERS
builtin_types.h
cooperative_groups.h
cuda_bf16.h
cuda_bf16.hpp
cuda_device_runtime_api.h
cuda_fp16.h
cuda_fp16.hpp
cuda_fp8.h
cuda_fp8.hpp
cuda_runtime_api.h
device_types.h
driver_types.h
math_constants.h
surface_types.h
texture_types.h
vector_functions.h
vector_functions.hpp
vector_types.h
)
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
install(FILES "${_cuda_inc}/${_hdr}"
DESTINATION ${_dst}
COMPONENT MLX)
endforeach()
# Subdirectory headers
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
DESTINATION ${_dst}
COMPONENT MLX
FILES_MATCHING PATTERN "*.h")
install(FILES "${_cuda_inc}/crt/host_defines.h"
DESTINATION "${_dst}/crt"
COMPONENT MLX)
endif()
endif()
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
# dlfcn-win32 is a known CMake target with its own install rules (which install
# to the wrong destination). We must install it explicitly here.
if(WIN32)
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
DESTINATION ${OLLAMA_INSTALL_DIR}
COMPONENT MLX)
endif()
# Manually install CUDA runtime libraries that MLX loads via dlopen
# (not detected by RUNTIME_DEPENDENCIES since they aren't link-time deps)
if(CUDAToolkit_FOUND)
file(GLOB MLX_CUDA_LIBS
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcublasLt.so*"
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
if(MLX_CUDA_LIBS)
install(FILES ${MLX_CUDA_LIBS}
DESTINATION ${OLLAMA_INSTALL_DIR}
COMPONENT MLX)
endif()
endif()
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/llama/server/CMakeLists.txt")
set(OLLAMA_HAVE_LLAMA_SERVER TRUE)
else()
set(OLLAMA_HAVE_LLAMA_SERVER FALSE)
endif()
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/local.cmake)
+5 -169
View File
@@ -11,109 +11,10 @@
}
},
{
"name": "CPU",
"inherits": [ "Default" ]
},
{
"name": "CUDA",
"inherits": [ "Default" ]
},
{
"name": "CUDA 11",
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "50-virtual;60-virtual;61-virtual;70-virtual;75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual",
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
"OLLAMA_RUNNER_DIR": "cuda_v11"
}
},
{
"name": "CUDA 12",
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "50;52;60;61;70;75;80;86;89;90;90a;120",
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
"OLLAMA_RUNNER_DIR": "cuda_v12"
}
},
{
"name": "CUDA 13",
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
"CMAKE_CUDA_FLAGS": "-t 2",
"OLLAMA_RUNNER_DIR": "cuda_v13"
}
},
{
"name": "JetPack 5",
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "72;87",
"OLLAMA_RUNNER_DIR": "cuda_jetpack5"
}
},
{
"name": "JetPack 6",
"inherits": [ "CUDA" ],
"cacheVariables": {
"CMAKE_CUDA_ARCHITECTURES": "87",
"OLLAMA_RUNNER_DIR": "cuda_jetpack6"
}
},
{
"name": "ROCm",
"name": "MLX Metal",
"inherits": [ "Default" ],
"cacheVariables": {
"CMAKE_HIP_PLATFORM": "amd"
}
},
{
"name": "ROCm 6",
"inherits": [ "ROCm" ],
"cacheVariables": {
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
"AMDGPU_TARGETS": "gfx940;gfx941;gfx942;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
"OLLAMA_RUNNER_DIR": "rocm"
}
},
{
"name": "ROCm 7",
"inherits": [ "ROCm" ],
"cacheVariables": {
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
"AMDGPU_TARGETS": "gfx942;gfx950;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1103;gfx1150;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
"OLLAMA_RUNNER_DIR": "rocm"
}
},
{
"name": "Vulkan",
"inherits": [ "Default" ],
"cacheVariables": {
"OLLAMA_RUNNER_DIR": "vulkan"
}
},
{
"name": "MLX",
"inherits": [ "Default" ],
"cacheVariables": {
"MLX_ENGINE": "ON",
"OLLAMA_RUNNER_DIR": "mlx"
}
},
{
"name": "MLX CUDA 12",
"inherits": [ "MLX", "CUDA 12" ],
"cacheVariables": {
"OLLAMA_RUNNER_DIR": "mlx_cuda_v12"
}
},
{
"name": "MLX CUDA 13",
"inherits": [ "MLX", "CUDA 13" ],
"cacheVariables": {
"MLX_CUDA_ARCHITECTURES": "86;89;90;90a;100;103;75-virtual;80-virtual;110-virtual;120-virtual;121-virtual",
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
"OLLAMA_MLX_BACKENDS": "metal_v3;metal_v4"
}
}
],
@@ -124,74 +25,9 @@
"configuration": "Release"
},
{
"name": "CPU",
"configurePreset": "Default",
"targets": [ "ggml-cpu" ]
},
{
"name": "CUDA",
"configurePreset": "CUDA",
"targets": [ "ggml-cuda" ]
},
{
"name": "CUDA 11",
"inherits": [ "CUDA" ],
"configurePreset": "CUDA 11"
},
{
"name": "CUDA 12",
"inherits": [ "CUDA" ],
"configurePreset": "CUDA 12"
},
{
"name": "CUDA 13",
"inherits": [ "CUDA" ],
"configurePreset": "CUDA 13"
},
{
"name": "JetPack 5",
"inherits": [ "CUDA" ],
"configurePreset": "JetPack 5"
},
{
"name": "JetPack 6",
"inherits": [ "CUDA" ],
"configurePreset": "JetPack 6"
},
{
"name": "ROCm",
"configurePreset": "ROCm",
"targets": [ "ggml-hip" ]
},
{
"name": "ROCm 6",
"inherits": [ "ROCm" ],
"configurePreset": "ROCm 6"
},
{
"name": "ROCm 7",
"inherits": [ "ROCm" ],
"configurePreset": "ROCm 7"
},
{
"name": "Vulkan",
"targets": [ "ggml-vulkan" ],
"configurePreset": "Vulkan"
},
{
"name": "MLX",
"targets": [ "mlx", "mlxc" ],
"configurePreset": "MLX"
},
{
"name": "MLX CUDA 12",
"targets": [ "mlx", "mlxc" ],
"configurePreset": "MLX CUDA 12"
},
{
"name": "MLX CUDA 13",
"targets": [ "mlx", "mlxc" ],
"configurePreset": "MLX CUDA 13"
"name": "MLX Metal",
"targets": [ "ollama-mlx-backends" ],
"configurePreset": "MLX Metal"
}
]
}
+160 -99
View File
@@ -37,113 +37,150 @@ RUN dnf install -y unzip \
ENV CMAKE_GENERATOR=Ninja
ENV LDFLAGS=-s
FROM base AS cpu
#
# GPU toolchain stages — provide compilers for llama-server GPU builds
#
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
COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'CPU' \
&& cmake --build --preset 'CPU' -- -l $(nproc) \
&& cmake --install build --component CPU --strip
FROM base AS cuda-11
ARG CUDA11VERSION=11.8
RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
ENV PATH=/usr/local/cuda-11/bin:$PATH
COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'CUDA 11' \
&& cmake --build --preset 'CUDA 11' -- -l $(nproc) \
&& cmake --install build --component CUDA --strip
FROM base AS cuda-12
FROM base AS cuda-12-deps
ARG CUDA12VERSION=12.8
RUN dnf install -y cuda-toolkit-${CUDA12VERSION//./-}
ENV PATH=/usr/local/cuda-12/bin:$PATH
COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'CUDA 12' \
&& cmake --build --preset 'CUDA 12' -- -l $(nproc) \
&& cmake --install build --component CUDA --strip
FROM base AS cuda-13
FROM base AS cuda-13-deps
ARG CUDA13VERSION=13.0
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-}
ENV PATH=/usr/local/cuda-13/bin:$PATH
COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'CUDA 13' \
&& cmake --build --preset 'CUDA 13' -- -l $(nproc) \
&& cmake --install build --component CUDA --strip
FROM base AS rocm-7-deps
ENV PATH=/opt/rocm/llvm/bin:/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:$PATH
FROM base AS rocm-7
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'ROCm 7' \
&& cmake --build --preset 'ROCm 7' -- -l $(nproc) \
&& cmake --install build --component HIP --strip
RUN rm -f dist/lib/ollama/rocm/rocblas/library/*gfx90[06]*
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
ARG CMAKEVERSION
ARG NINJAVERSION
RUN apt-get update && apt-get install -y curl ccache unzip \
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
&& unzip /tmp/ninja.zip -d /usr/local/bin \
&& rm /tmp/ninja.zip
ENV CMAKE_GENERATOR=Ninja
COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'JetPack 5' \
&& cmake --build --preset 'JetPack 5' -- -l $(nproc) \
&& cmake --install build --component CUDA --strip
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
ARG CMAKEVERSION
ARG NINJAVERSION
RUN apt-get update && apt-get install -y curl ccache unzip \
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
&& unzip /tmp/ninja.zip -d /usr/local/bin \
&& rm /tmp/ninja.zip
ENV CMAKE_GENERATOR=Ninja
COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'JetPack 6' \
&& cmake --build --preset 'JetPack 6' -- -l $(nproc) \
&& cmake --install build --component CUDA --strip
FROM base AS vulkan
FROM base AS vulkan-deps
ARG VULKANVERSION
RUN ln -s /usr/bin/python3 /usr/bin/python \
&& wget https://sdk.lunarg.com/sdk/download/${VULKANVERSION}/linux/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz -O /tmp/vulkansdk.tar.xz \
&& tar xvf /tmp/vulkansdk.tar.xz -C /tmp \
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 vulkan-headers \
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 spirv-headers \
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 shaderc \
&& cp -r /tmp/${VULKANVERSION}/x86_64/include/* /usr/local/include/ \
&& cp -r /tmp/${VULKANVERSION}/x86_64/lib/* /usr/local/lib \
&& cp -r /tmp/${VULKANVERSION}/x86_64/share/* /usr/local/share/ \
&& cp -r /tmp/${VULKANVERSION}/x86_64/bin/* /usr/local/bin/ \
&& rm -rf /tmp/${VULKANVERSION} /tmp/vulkansdk.tar.xz
COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
ENV VULKAN_SDK=/usr/local
#
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, or llama/compat/ changes.
#
# CPU stage: llama-server + ggml-base + ggml-cpu variants → lib/ollama/
# GPU stages: GPU backend .so only → lib/ollama/<variant>/
#
FROM cpu-deps AS llama-server-cpu
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
RUN --mount=type=cache,target=/root/.ccache \
cmake --preset 'Vulkan' \
&& cmake --build --preset 'Vulkan' -- -l $(nproc) \
&& cmake --install build --component Vulkan --strip
cmake -S llama/server --preset cpu \
&& cmake --build build/llama-server-cpu -- -l $(nproc) \
&& cmake --install build/llama-server-cpu --component llama-server --strip \
&& 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 \
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
done
FROM cuda-12-deps AS llama-server-cuda_v12
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset llama_cuda_v12_linux \
&& cmake --build build/llama-server-cuda_v12 -- -l $(nproc) \
&& cmake --install build/llama-server-cuda_v12 --component llama-server --strip
FROM cuda-13-deps AS llama-server-cuda_v13
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset llama_cuda_v13_linux \
&& cmake --build build/llama-server-cuda_v13 -- -l $(nproc) \
&& cmake --install build/llama-server-cuda_v13 --component llama-server --strip
FROM rocm-7-deps AS llama-server-rocm_v7_2
ENV CC=clang CXX=clang++
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset rocm_v7_2_linux \
&& cmake --build build/llama-server-rocm_v7_2 -- -l $(nproc) \
&& cmake --install build/llama-server-rocm_v7_2 --component llama-server --strip
RUN rm -f dist/lib/ollama/rocm_v7_2/rocblas/library/*gfx90[06]*
FROM vulkan-deps AS llama-server-vulkan
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset vulkan \
&& cmake --build build/llama-server-vulkan -- -l $(nproc) \
&& cmake --install build/llama-server-vulkan --component llama-server --strip
#
# JetPack stages — self-contained with their own base images
#
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
ARG CMAKEVERSION
ARG NINJAVERSION
RUN apt-get update && apt-get install -y curl ccache git unzip \
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
&& unzip /tmp/ninja.zip -d /usr/local/bin \
&& rm /tmp/ninja.zip
ENV CMAKE_GENERATOR=Ninja
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset llama_cuda_jetpack5 \
&& cmake --build build/llama-server-cuda_jetpack5 -- -l $(nproc) \
&& cmake --install build/llama-server-cuda_jetpack5 --component llama-server --strip
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
ARG CMAKEVERSION
ARG NINJAVERSION
RUN apt-get update && apt-get install -y curl ccache git unzip \
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
&& unzip /tmp/ninja.zip -d /usr/local/bin \
&& rm /tmp/ninja.zip
ENV CMAKE_GENERATOR=Ninja
COPY LLAMA_CPP_VERSION .
COPY llama/server llama/server
COPY llama/compat llama/compat
RUN --mount=type=cache,target=/root/.ccache \
cmake -S llama/server --preset llama_cuda_jetpack6 \
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
&& cmake --install build/llama-server-cuda_jetpack6 --component llama-server --strip
#
# MLX stage
#
FROM base AS mlx
ARG CUDA13VERSION=13.0
ARG OLLAMA_MLX_BUILD_JOBS=
ARG OLLAMA_MLX_NVCC_THREADS=2
ARG MLX_CUDA_RAM_MB=
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-} \
&& dnf install -y openblas-devel lapack-devel \
&& dnf install -y libcudnn9-cuda-13 libcudnn9-devel-cuda-13 \
@@ -154,7 +191,7 @@ ENV LAPACK_INCLUDE_DIRS=/usr/include/openblas
ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-linux/lib/stubs"
WORKDIR /go/src/github.com/ollama/ollama
COPY CMakeLists.txt CMakePresets.json .
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
COPY cmake cmake
COPY x/imagegen/mlx x/imagegen/mlx
COPY go.mod go.sum .
COPY MLX_VERSION MLX_C_VERSION .
@@ -170,9 +207,12 @@ RUN --mount=type=cache,target=/root/.ccache \
&& if [ -f /tmp/local-mlx-c/CMakeLists.txt ]; then \
export OLLAMA_MLX_C_SOURCE=/tmp/local-mlx-c; \
fi \
&& cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas \
&& cmake --build --preset 'MLX CUDA 13' -- -l $(nproc) \
&& cmake --install build --component MLX --strip
&& cmake -S . -B build/mlx_cuda_v13 -DOLLAMA_MLX_BACKENDS=cuda_v13 -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" ${MLX_CUDA_RAM_MB:+-DMLX_CUDA_RAM_MB=${MLX_CUDA_RAM_MB}} -DOLLAMA_PAYLOAD_INSTALL_PREFIX=/go/src/github.com/ollama/ollama/dist \
&& cmake --build build/mlx_cuda_v13 --target ollama-mlx-cuda_v13 -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}}
#
# Go build
#
FROM base AS build
WORKDIR /go/src/github.com/ollama/ollama
@@ -190,38 +230,59 @@ ENV CGO_CXXFLAGS="${CGO_CXXFLAGS}"
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -buildmode=pie -o /bin/ollama .
#
# Assembly stages — combine llama-server variants + GPU runtime libs
#
FROM --platform=linux/amd64 scratch AS amd64
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
COPY --from=cuda-13 dist/lib/ollama /lib/ollama/
COPY --from=vulkan dist/lib/ollama /lib/ollama/
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
FROM --platform=linux/arm64 scratch AS arm64
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
COPY --from=cuda-13 dist/lib/ollama/ /lib/ollama/
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
COPY --from=jetpack-5 dist/lib/ollama/ /lib/ollama/
COPY --from=jetpack-6 dist/lib/ollama/ /lib/ollama/
FROM scratch AS rocm
COPY --from=rocm-7 dist/lib/ollama /lib/ollama
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama
FROM ${FLAVOR} AS archive
COPY --from=cpu dist/lib/ollama /lib/ollama
FROM --platform=linux/amd64 scratch AS amd64-archive
COPY --from=amd64 /lib/ollama /lib/ollama/
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
FROM --platform=linux/arm64 scratch AS arm64-archive
COPY --from=arm64 /lib/ollama /lib/ollama/
FROM ${TARGETARCH}-archive AS archive
COPY --from=build /bin/ollama /bin/ollama
FROM ${FLAVOR} AS image-archive
COPY --from=build /bin/ollama /bin/ollama
FROM ubuntu:24.04
ARG APT_MIRROR=http://archive.ubuntu.com/ubuntu
RUN sed -i "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" /etc/apt/sources.list.d/ubuntu.sources \
ARG APT_PORTS_MIRROR=http://ports.ubuntu.com/ubuntu-ports
RUN sed -i \
-e "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" \
-e "s|http://ports.ubuntu.com/ubuntu-ports|$APT_PORTS_MIRROR|g" \
/etc/apt/sources.list.d/ubuntu.sources \
&& apt-get update \
&& apt-get install -y ca-certificates libvulkan1 libopenblas0 \
&& sed -i "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" /etc/apt/sources.list.d/ubuntu.sources \
&& sed -i \
-e "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" \
-e "s|$APT_PORTS_MIRROR|http://ports.ubuntu.com/ubuntu-ports|g" \
/etc/apt/sources.list.d/ubuntu.sources \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY --from=archive /bin /usr/bin
COPY --from=image-archive /bin /usr/bin
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
COPY --from=archive /lib/ollama /usr/lib/ollama
COPY --from=image-archive /lib/ollama /usr/lib/ollama
ENV LD_LIBRARY_PATH=/usr/local/nvidia/lib:/usr/local/nvidia/lib64
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
ENV NVIDIA_VISIBLE_DEVICES=all
+1
View File
@@ -0,0 +1 @@
b9409
+1 -1
View File
@@ -1 +1 @@
e8ebdebeeb655feaa85a51f6b24ece5b6d5518d1
2165dc08d7b33258260aa849d39f087d50e62962
-76
View File
@@ -1,76 +0,0 @@
UPSTREAM=https://github.com/ggml-org/llama.cpp.git
WORKDIR=llama/vendor
FETCH_HEAD=ec98e2002
.PHONY: help
help:
@echo "Available targets:"
@echo " sync Sync with upstream repositories"
@echo " checkout Checkout upstream repository"
@echo " apply-patches Apply patches to local repository"
@echo " format-patches Format patches from local repository"
@echo " clean Clean local repository"
@echo
@echo "Example:"
@echo " make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches sync"
.PHONY: sync
sync: llama/build-info.cpp ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal
llama/build-info.cpp: llama/build-info.cpp.in llama/llama.cpp
sed -e 's|@FETCH_HEAD@|$(FETCH_HEAD)|' <$< >$@
ml/backend/ggml/ggml/src/ggml-metal/ggml-metal-embed.metal: ml/backend/ggml/ggml
go generate ./$(@D)
.PHONY: llama/llama.cpp
llama/llama.cpp: llama/vendor
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /) $@
.PHONY: ml/backend/ggml/ggml
ml/backend/ggml/ggml: llama/vendor
rsync -arvzc --delete -f "include LICENSE" -f "merge $@/.rsync-filter" $(addprefix $<,/LICENSE /ggml/) $@
PATCHES=$(wildcard llama/patches/*.patch)
PATCHED=$(join $(dir $(PATCHES)), $(addsuffix ed, $(addprefix ., $(notdir $(PATCHES)))))
.PHONY: apply-patches
.NOTPARALLEL:
apply-patches: $(PATCHED)
llama/patches/.%.patched: llama/patches/%.patch
@if git -c user.name=nobody -c 'user.email=<>' -C $(WORKDIR) am -3 $(realpath $<); then \
touch $@; \
else \
echo "Patch failed. Resolve any conflicts then continue."; \
echo "1. Run 'git -C $(WORKDIR) am --continue'"; \
echo "2. Run 'make -f $(lastword $(MAKEFILE_LIST)) format-patches'"; \
echo "3. Run 'make -f $(lastword $(MAKEFILE_LIST)) clean apply-patches'"; \
exit 1; \
fi
.PHONY: checkout
checkout: $(WORKDIR)
git -C $(WORKDIR) fetch
git -C $(WORKDIR) checkout -f $(FETCH_HEAD)
$(WORKDIR):
git clone $(UPSTREAM) $(WORKDIR)
.PHONY: format-patches
format-patches: llama/patches
git -C $(WORKDIR) format-patch \
--no-signature \
--no-numbered \
--zero-commit \
-o $(realpath $<) \
$(FETCH_HEAD)
.PHONY: clean
clean: checkout
@git -C $(WORKDIR) am --abort || true
$(RM) llama/patches/.*.patched
.PHONY: print-base
print-base:
@echo $(FETCH_HEAD)
+106 -28
View File
@@ -78,6 +78,11 @@ type MessagesRequest struct {
ToolChoice *ToolChoice `json:"tool_choice,omitempty"`
Thinking *ThinkingConfig `json:"thinking,omitempty"`
Metadata *Metadata `json:"metadata,omitempty"`
OutputConfig *OutputConfig `json:"output_config,omitempty"`
}
type OutputConfig struct {
Effort string `json:"effort,omitempty"`
}
// MessageParam represents a message in the request
@@ -161,7 +166,7 @@ type WebSearchToolResultError struct {
// ImageSource represents the source of an image
type ImageSource struct {
Type string `json:"type"` // "base64" or "url"
Type string `json:"type"` // "base64"
MediaType string `json:"media_type,omitempty"`
Data string `json:"data,omitempty"`
URL string `json:"url,omitempty"`
@@ -373,9 +378,26 @@ func FromMessagesRequest(r MessagesRequest) (*api.ChatRequest, error) {
}
var think *api.ThinkValue
normalizedEffort := ""
if r.OutputConfig != nil {
normalizedEffort = strings.ToLower(strings.TrimSpace(r.OutputConfig.Effort))
if normalizedEffort == "xhigh" {
normalizedEffort = "high"
}
}
if r.Thinking != nil && r.Thinking.Type == "enabled" {
think = &api.ThinkValue{Value: true}
}
if r.Thinking != nil && r.Thinking.Type == "disabled" {
think = &api.ThinkValue{Value: false}
}
if think == nil && r.OutputConfig != nil {
switch normalizedEffort {
case "high", "medium", "low", "max":
think = &api.ThinkValue{Value: normalizedEffort}
}
}
stream := r.Stream
convertedRequest := &api.ChatRequest{
@@ -425,17 +447,12 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
return nil, errors.New("invalid image source")
}
if block.Source.Type == "base64" {
decoded, err := base64.StdEncoding.DecodeString(block.Source.Data)
if err != nil {
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
return nil, fmt.Errorf("invalid base64 image data: %w", err)
}
images = append(images, decoded)
} else {
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", block.Source.Type)
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", block.Source.Type)
decoded, err := resolveImageSource(block.Source)
if err != nil {
logutil.Trace("anthropic: unsupported image source", "role", role, "source_type", block.Source.Type, "error", err)
return nil, err
}
images = append(images, decoded)
case "tool_use":
toolUseBlocks++
@@ -457,26 +474,16 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
case "tool_result":
toolResultBlocks++
var resultContent string
switch c := block.Content.(type) {
case string:
resultContent = c
case []any:
for _, cb := range c {
if cbMap, ok := cb.(map[string]any); ok {
if cbMap["type"] == "text" {
if text, ok := cbMap["text"].(string); ok {
resultContent += text
}
}
}
}
resultContent, resultImages, err := convertToolResultContent(block.Content)
if err != nil {
logutil.Trace("anthropic: invalid tool_result content", "role", role, "error", err)
return nil, err
}
toolResults = append(toolResults, api.Message{
Role: "tool",
Content: resultContent,
Images: resultImages,
ToolCallID: block.ToolUseID,
})
@@ -508,6 +515,10 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
}
}
if role == "user" && len(toolResults) > 0 {
messages = append(messages, toolResults...)
}
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
m := api.Message{
Role: role,
@@ -519,8 +530,10 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
messages = append(messages, m)
}
// Add tool results as separate messages
messages = append(messages, toolResults...)
// Add tool results as separate messages.
if role != "user" || len(toolResults) == 0 {
messages = append(messages, toolResults...)
}
logutil.Trace("anthropic: converted block message",
"role", role,
"blocks", len(msg.Content),
@@ -969,6 +982,71 @@ func GenerateMessageID() string {
return generateID("msg")
}
func resolveImageSource(source *ImageSource) (api.ImageData, error) {
if source.Type != "base64" {
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", source.Type)
}
decoded, err := base64.StdEncoding.DecodeString(source.Data)
if err != nil {
return nil, fmt.Errorf("invalid base64 image data: %w", err)
}
return decoded, nil
}
func convertToolResultContent(content any) (string, []api.ImageData, error) {
switch c := content.(type) {
case nil:
return "", nil, nil
case string:
return c, nil, nil
case []any:
var text strings.Builder
var images []api.ImageData
for _, cb := range c {
cbMap, ok := cb.(map[string]any)
if !ok {
continue
}
switch cbMap["type"] {
case "text":
if t, ok := cbMap["text"].(string); ok {
text.WriteString(t)
}
case "image":
rawSource, ok := cbMap["source"].(map[string]any)
if !ok {
return "", nil, errors.New("invalid tool_result image source")
}
var source ImageSource
if rawType, ok := rawSource["type"].(string); ok {
source.Type = rawType
}
if rawMediaType, ok := rawSource["media_type"].(string); ok {
source.MediaType = rawMediaType
}
if rawData, ok := rawSource["data"].(string); ok {
source.Data = rawData
}
img, err := resolveImageSource(&source)
if err != nil {
return "", nil, err
}
images = append(images, img)
}
}
return text.String(), images, nil
default:
return "", nil, nil
}
}
// ptr returns a pointer to the given string value
func ptr(s string) *string {
return &s
+235
View File
@@ -271,6 +271,241 @@ func TestFromMessagesRequest_WithToolResult(t *testing.T) {
}
}
func TestFromMessagesRequest_WithToolResultImage(t *testing.T) {
imgData, _ := base64.StdEncoding.DecodeString(testImage)
req := MessagesRequest{
Model: "test-model",
MaxTokens: 1024,
Messages: []MessageParam{
{
Role: "user",
Content: []ContentBlock{
{
Type: "tool_result",
ToolUseID: "call_img",
Content: []any{
map[string]any{"type": "text", "text": "Attached image"},
map[string]any{
"type": "image",
"source": map[string]any{
"type": "base64",
"media_type": "image/png",
"data": testImage,
},
},
},
},
},
},
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Messages) != 1 {
t.Fatalf("expected 1 message, got %d", len(result.Messages))
}
msg := result.Messages[0]
if msg.Role != "tool" {
t.Errorf("expected role 'tool', got %q", msg.Role)
}
if msg.ToolCallID != "call_img" {
t.Errorf("expected tool_call_id 'call_img', got %q", msg.ToolCallID)
}
if msg.Content != "Attached image" {
t.Errorf("unexpected content: %q", msg.Content)
}
if len(msg.Images) != 1 {
t.Fatalf("expected 1 image, got %d", len(msg.Images))
}
if string(msg.Images[0]) != string(imgData) {
t.Error("image data mismatch")
}
}
func TestFromMessagesRequest_WithToolResultFollowedByUserText(t *testing.T) {
req := MessagesRequest{
Model: "test-model",
MaxTokens: 1024,
Messages: []MessageParam{
{
Role: "assistant",
Content: []ContentBlock{
{
Type: "tool_use",
ID: "call_read",
Name: "Read",
Input: makeArgs("file_path", "/Users/hoyyeva/Desktop/aaa.png"),
},
},
},
{
Role: "user",
Content: []ContentBlock{
{
Type: "tool_result",
ToolUseID: "call_read",
Content: "Read image (311.5KB)",
},
{
Type: "text",
Text: ptr("Please describe it."),
},
},
},
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result.Messages) != 3 {
t.Fatalf("expected 3 messages, got %d", len(result.Messages))
}
if result.Messages[1].Role != "tool" {
t.Fatalf("expected second message to be tool, got %q", result.Messages[1].Role)
}
if result.Messages[1].ToolCallID != "call_read" {
t.Fatalf("expected tool_call_id 'call_read', got %q", result.Messages[1].ToolCallID)
}
if result.Messages[2].Role != "user" {
t.Fatalf("expected third message to be user, got %q", result.Messages[2].Role)
}
if result.Messages[2].Content != "Please describe it." {
t.Fatalf("unexpected user content: %q", result.Messages[2].Content)
}
}
func TestFromMessagesRequest_WithOutputConfigEffort(t *testing.T) {
req := MessagesRequest{
Model: "gemma4",
MaxTokens: 32000,
Messages: []MessageParam{
{
Role: "user",
Content: textContent("Describe the image."),
},
},
OutputConfig: &OutputConfig{
Effort: "high",
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Think == nil {
t.Fatal("expected think to be set from output_config.effort")
}
if got := result.Think.String(); got != "high" {
t.Fatalf("expected think level 'high', got %q", got)
}
}
func TestFromMessagesRequest_WithOutputConfigEffortXHighMapsToHigh(t *testing.T) {
req := MessagesRequest{
Model: "gemma4",
MaxTokens: 32000,
Messages: []MessageParam{
{
Role: "user",
Content: textContent("Describe the image."),
},
},
OutputConfig: &OutputConfig{
Effort: "xhigh",
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Think == nil {
t.Fatal("expected think to be set from output_config.effort")
}
if got := result.Think.String(); got != "high" {
t.Fatalf("expected think level 'high' for xhigh effort, got %q", got)
}
}
func TestFromMessagesRequest_ThinkingDisabledOverridesOutputConfigEffort(t *testing.T) {
req := MessagesRequest{
Model: "gemma4",
MaxTokens: 32000,
Messages: []MessageParam{
{
Role: "user",
Content: textContent("Describe the image."),
},
},
Thinking: &ThinkingConfig{
Type: "disabled",
},
OutputConfig: &OutputConfig{
Effort: "high",
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Think == nil {
t.Fatal("expected think to be set")
}
if got := result.Think.Value; got != false {
t.Fatalf("expected think=false when thinking is disabled, got %v", got)
}
}
func TestFromMessagesRequest_ThinkingAdaptiveUsesOutputConfigEffort(t *testing.T) {
req := MessagesRequest{
Model: "gemma4",
MaxTokens: 32000,
Messages: []MessageParam{
{
Role: "user",
Content: textContent("Describe the image."),
},
},
Thinking: &ThinkingConfig{
Type: "adaptive",
},
OutputConfig: &OutputConfig{
Effort: "high",
},
}
result, err := FromMessagesRequest(req)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Think == nil {
t.Fatal("expected think to be set from output_config.effort")
}
if got := result.Think.String(); got != "high" {
t.Fatalf("expected think level 'high' for adaptive thinking, got %q", got)
}
}
func TestFromMessagesRequest_WithTools(t *testing.T) {
req := MessagesRequest{
Model: "test-model",
+4
View File
@@ -259,6 +259,10 @@ func (c *Client) stream(ctx context.Context, method, path string, data any, fn f
}
}
if err := scanner.Err(); err != nil {
return err
}
return nil
}
+50
View File
@@ -3,6 +3,7 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
@@ -192,6 +193,35 @@ func TestClientStream(t *testing.T) {
}
}
func TestClientStreamReportsReadErrors(t *testing.T) {
client := NewClient(
&url.URL{Scheme: "http", Host: "example.com"},
&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
body := failingReader{
data: []byte(`{"message":{"content":"partial"}}` + "\n"),
err: io.ErrUnexpectedEOF,
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Body: io.NopCloser(&body),
Header: make(http.Header),
}, nil
})},
)
err := client.stream(t.Context(), http.MethodPost, "/api/chat", nil, func([]byte) error {
return nil
})
if err == nil {
t.Fatal("expected stream read error")
}
if !strings.Contains(err.Error(), io.ErrUnexpectedEOF.Error()) {
t.Fatalf("expected unexpected EOF, got %v", err)
}
}
func TestClientDo(t *testing.T) {
testCases := []struct {
name string
@@ -320,3 +350,23 @@ func TestClientDo(t *testing.T) {
})
}
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
type failingReader struct {
data []byte
err error
}
func (r *failingReader) Read(p []byte) (int, error) {
if len(r.data) > 0 {
n := copy(p, r.data)
r.data = r.data[n:]
return n, nil
}
return 0, r.err
}
+53 -25
View File
@@ -600,12 +600,13 @@ type Options struct {
// Runner options which must be set when the model is loaded into memory
type Runner struct {
NumCtx int `json:"num_ctx,omitempty"`
NumBatch int `json:"num_batch,omitempty"`
NumGPU int `json:"num_gpu,omitempty"`
MainGPU int `json:"main_gpu,omitempty"`
UseMMap *bool `json:"use_mmap,omitempty"`
NumThread int `json:"num_thread,omitempty"`
NumCtx int `json:"num_ctx,omitempty"`
NumBatch int `json:"num_batch,omitempty"`
NumGPU int `json:"num_gpu,omitempty"`
MainGPU *int `json:"main_gpu,omitempty"`
UseMMap *bool `json:"use_mmap,omitempty"`
NumThread int `json:"num_thread,omitempty"`
DraftNumPredict int `json:"draft_num_predict,omitempty"`
}
// EmbedRequest is the request passed to [Client.Embed].
@@ -672,6 +673,9 @@ type CreateRequest struct {
// Quantize is the quantization format for the model; leave blank to not change the quantization level.
Quantize string `json:"quantize,omitempty"`
// DraftQuantize is the quantization format for the draft model.
DraftQuantize string `json:"draft_quantize,omitempty"`
// From is the name of the model or file to use as the source.
From string `json:"from,omitempty"`
@@ -681,6 +685,9 @@ type CreateRequest struct {
// Files is a map of files include when creating the model.
Files map[string]string `json:"files,omitempty"`
// DraftFiles is a map of draft model files to include when creating the model.
DraftFiles map[string]string `json:"draft_files,omitempty"`
// Adapters is a map of LoRA adapters to include when creating the model.
Adapters map[string]string `json:"adapters,omitempty"`
@@ -824,14 +831,15 @@ type ProcessResponse struct {
// ListModelResponse is a single model description in [ListResponse].
type ListModelResponse struct {
Name string `json:"name"`
Model string `json:"model"`
RemoteModel string `json:"remote_model,omitempty"`
RemoteHost string `json:"remote_host,omitempty"`
ModifiedAt time.Time `json:"modified_at"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Details ModelDetails `json:"details,omitempty"`
Name string `json:"name"`
Model string `json:"model"`
RemoteModel string `json:"remote_model,omitempty"`
RemoteHost string `json:"remote_host,omitempty"`
ModifiedAt time.Time `json:"modified_at"`
Size int64 `json:"size"`
Digest string `json:"digest"`
Details ModelDetails `json:"details,omitempty"`
Capabilities []model.Capability `json:"capabilities,omitempty"`
}
// ProcessModelResponse is a single model description in [ProcessResponse].
@@ -924,6 +932,8 @@ type ModelDetails struct {
Families []string `json:"families"`
ParameterSize string `json:"parameter_size"`
QuantizationLevel string `json:"quantization_level"`
ContextLength int `json:"context_length,omitempty"`
EmbeddingLength int `json:"embedding_length,omitempty"`
}
// UserResponse provides information about a user.
@@ -1046,14 +1056,25 @@ func (opts *Options) FromMap(m map[string]any) error {
}
field.Set(reflect.ValueOf(slice))
case reflect.Pointer:
var b bool
if field.Type() == reflect.TypeOf(&b) {
switch field.Type().Elem().Kind() {
case reflect.Bool:
val, ok := val.(bool)
if !ok {
return fmt.Errorf("option %q must be of type boolean", key)
}
field.Set(reflect.ValueOf(&val))
} else {
case reflect.Int:
var i int
switch t := val.(type) {
case int64:
i = int(t)
case float64:
i = int(t)
default:
return fmt.Errorf("option %q must be of type integer", key)
}
field.Set(reflect.ValueOf(&i))
default:
return fmt.Errorf("unknown type loading config params: %v %v", field.Kind(), field.Type())
}
default:
@@ -1086,11 +1107,12 @@ func DefaultOptions() Options {
Runner: Runner{
// options set when the model is loaded
NumCtx: int(envconfig.ContextLength()),
NumBatch: 512,
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
NumThread: 0, // let the runtime decide
UseMMap: nil,
NumCtx: int(envconfig.ContextLength()),
NumBatch: 512,
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
NumThread: 0, // let the runtime decide
DraftNumPredict: 4,
UseMMap: nil,
},
}
}
@@ -1294,14 +1316,20 @@ func FormatParams(params map[string][]string) (map[string]any, error) {
// TODO: only string slices are supported right now
out[key] = vals
case reflect.Pointer:
var b bool
if field.Type() == reflect.TypeOf(&b) {
switch field.Type().Elem().Kind() {
case reflect.Bool:
boolVal, err := strconv.ParseBool(vals[0])
if err != nil {
return nil, fmt.Errorf("invalid bool value %s", vals)
}
out[key] = &boolVal
} else {
case reflect.Int:
intVal, err := strconv.ParseInt(vals[0], 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid int value %s", vals)
}
out[key] = intVal
default:
return nil, fmt.Errorf("unknown type %s for %s", field.Kind(), key)
}
default:
+51
View File
@@ -20,6 +20,10 @@ func testPropsMap(m map[string]ToolProperty) *ToolPropertiesMap {
return props
}
func testIntPtr(v int) *int {
return &v
}
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests, order not preserved)
func testArgs(m map[string]any) ToolCallFunctionArguments {
args := NewToolCallFunctionArguments()
@@ -168,6 +172,47 @@ func TestUseMmapParsingFromJSON(t *testing.T) {
}
}
func TestMainGPUParsingFromJSON(t *testing.T) {
tests := []struct {
name string
req string
wantGPU *int
}{
{
name: "Undefined",
req: `{}`,
},
{
name: "Zero",
req: `{ "main_gpu": 0 }`,
wantGPU: testIntPtr(0),
},
{
name: "Nonzero",
req: `{ "main_gpu": 1 }`,
wantGPU: testIntPtr(1),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var oMap map[string]any
err := json.Unmarshal([]byte(test.req), &oMap)
require.NoError(t, err)
opts := DefaultOptions()
err = opts.FromMap(oMap)
require.NoError(t, err)
if test.wantGPU == nil {
assert.Nil(t, opts.MainGPU)
} else if assert.NotNil(t, opts.MainGPU) {
assert.Equal(t, *test.wantGPU, *opts.MainGPU)
}
})
}
}
func TestUseMmapFormatParams(t *testing.T) {
tr := true
fa := false
@@ -232,6 +277,12 @@ func TestUseMmapFormatParams(t *testing.T) {
}
}
func TestMainGPUFormatParams(t *testing.T) {
resp, err := FormatParams(map[string][]string{"main_gpu": {"0"}})
require.NoError(t, err)
assert.Equal(t, int64(0), resp["main_gpu"])
}
func TestMessage_UnmarshalJSON(t *testing.T) {
tests := []struct {
input string
+4 -49
View File
@@ -90,9 +90,8 @@ DialogFontSize=12
[Files]
#if FileExists("..\dist\windows-ollama-app-amd64.exe")
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
Source: "..\dist\windows-amd64\vc_redist.x64.exe"; DestDir: "{tmp}"; Check: not IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
Source: "..\dist\windows-amd64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
Source: "..\dist\windows-amd64\lib\ollama\*"; Excludes: "\mlx_*\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
#endif
; For local development, rely on binary compatibility at runtime since we can't cross compile
@@ -103,9 +102,11 @@ Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#M
#endif
#if FileExists("..\dist\windows-arm64\ollama.exe")
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
#endif
#if DirExists("..\dist\windows-arm64\lib\ollama")
Source: "..\dist\windows-arm64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: IsArm64(); Flags: ignoreversion 64bit recursesubdirs
#endif
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
@@ -118,12 +119,6 @@ Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFile
Type: files; Name: "{%LOCALAPPDATA}\Ollama\updates"
[Run]
#if DirExists("..\dist\windows-arm64")
Filename: "{tmp}\vc_redist.arm64.exe"; Parameters: "/install /passive /norestart"; Check: IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
#endif
#if DirExists("..\dist\windows-amd64")
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /passive /norestart"; Check: not IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
#endif
Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden
[UninstallRun]
@@ -184,46 +179,6 @@ begin
Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0;
end;
{ --- VC Runtime libraries discovery code - Only install vc_redist if it isn't already installed ----- }
const VCRTL_MIN_V1 = 14;
const VCRTL_MIN_V2 = 40;
const VCRTL_MIN_V3 = 33807;
const VCRTL_MIN_V4 = 0;
// check if the minimum required vc redist is installed (by looking the registry)
function vc_redist_needed (): Boolean;
var
sRegKey: string;
v1: Cardinal;
v2: Cardinal;
v3: Cardinal;
v4: Cardinal;
begin
if (IsArm64()) then begin
sRegKey := 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64';
end else begin
sRegKey := 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64';
end;
if (RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Major', v1) and
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'RBld', v4)) then
begin
Log ('VC Redist version: ' + IntToStr (v1) +
'.' + IntToStr (v2) + '.' + IntToStr (v3) +
'.' + IntToStr (v4));
{ Version info was found. Return true if later or equal to our
minimal required version RTL_MIN_Vx }
Result := not (
(v1 > VCRTL_MIN_V1) or ((v1 = VCRTL_MIN_V1) and
((v2 > VCRTL_MIN_V2) or ((v2 = VCRTL_MIN_V2) and
((v3 > VCRTL_MIN_V3) or ((v3 = VCRTL_MIN_V3) and
(v4 >= VCRTL_MIN_V4)))))));
end
else
Result := TRUE;
end;
function GetDirSize(Path: String): Int64;
var
FindRec: TFindRec;
+10 -9
View File
@@ -1201,15 +1201,16 @@ func (db *database) getSettings() (Settings, error) {
func (db *database) setSettings(s Settings) error {
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
validLaunchView := map[string]struct{}{
"launch": {},
"openclaw": {},
"claude": {},
"hermes": {},
"codex": {},
"copilot": {},
"opencode": {},
"droid": {},
"pi": {},
"launch": {},
"openclaw": {},
"claude": {},
"hermes": {},
"codex": {},
"codex-app": {},
"copilot": {},
"opencode": {},
"droid": {},
"pi": {},
}
if lastHomeView != "chat" {
if _, ok := validLaunchView[lastHomeView]; !ok {
+15
View File
@@ -122,6 +122,21 @@ func TestStore(t *testing.T) {
}
})
t.Run("settings codex app home view is accepted", func(t *testing.T) {
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
t.Fatal(err)
}
loaded, err := s.Settings()
if err != nil {
t.Fatal(err)
}
if loaded.LastHomeView != "codex-app" {
t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView)
}
})
t.Run("window size", func(t *testing.T) {
if err := s.SetWindowSize(1024, 768); err != nil {
t.Fatal(err)
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

+13 -5
View File
@@ -22,11 +22,12 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
iconClassName: "h-7 w-7",
},
{
id: "openclaw",
name: "OpenClaw",
command: "ollama launch openclaw",
description: "Personal AI with 100+ skills",
icon: "/launch-icons/openclaw.svg",
id: "codex-app",
name: "Codex App",
command: "ollama launch codex-app",
description: "An AI agent you can delegate real work to, by OpenAI",
icon: "/launch-icons/codex-app.png",
iconClassName: "h-full w-full",
},
{
id: "hermes",
@@ -36,6 +37,13 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
icon: "/launch-icons/hermes-agent.svg",
iconClassName: "h-7 w-7",
},
{
id: "openclaw",
name: "OpenClaw",
command: "ollama launch openclaw",
description: "Personal AI with 100+ skills",
icon: "/launch-icons/openclaw.svg",
},
{
id: "opencode",
name: "OpenCode",
+73 -14
View File
@@ -5,6 +5,8 @@ package updater
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -169,22 +171,20 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
if err != nil {
return fmt.Errorf("error checking update: %w", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
}
resp.Body.Close()
etag := strings.Trim(resp.Header.Get("etag"), "\"")
if etag == "" {
slog.Debug("no etag detected, falling back to filename based dedup")
etag = "_"
}
filename := Installer
_, params, err := mime.ParseMediaType(resp.Header.Get("content-disposition"))
if err == nil {
if err == nil && params["filename"] != "" {
filename = params["filename"]
}
stageFilename := filepath.Join(UpdateStageDir, etag, filename)
stageFilename, err := updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename)
if err != nil {
return err
}
// Check to see if we already have it downloaded
_, err = os.Stat(stageFilename)
@@ -202,13 +202,14 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
return fmt.Errorf("error checking update: %w", err)
}
defer resp.Body.Close()
etag = strings.Trim(resp.Header.Get("etag"), "\"")
if etag == "" {
slog.Debug("no etag detected, falling back to filename based dedup") // TODO probably can get rid of this redundant log
etag = "_"
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
}
stageFilename = filepath.Join(UpdateStageDir, etag, filename)
stageFilename, err = updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename)
if err != nil {
return err
}
_, err = os.Stat(filepath.Dir(stageFilename))
if errors.Is(err, os.ErrNotExist) {
@@ -225,10 +226,13 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
if err != nil {
return fmt.Errorf("write payload %s: %w", stageFilename, err)
}
defer fp.Close()
if n, err := fp.Write(payload); err != nil || n != len(payload) {
_ = fp.Close()
return fmt.Errorf("write payload %s: %d vs %d -- %w", stageFilename, n, len(payload), err)
}
if err := fp.Close(); err != nil {
return fmt.Errorf("close payload %s: %w", stageFilename, err)
}
slog.Info("new update downloaded " + stageFilename)
if err := VerifyDownload(); err != nil {
@@ -239,6 +243,61 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
return nil
}
func updateStagePath(stageDir, etag, filename string) (string, error) {
filename, err := safeUpdateFilename(filename)
if err != nil {
return "", err
}
stageDir, err = filepath.Abs(stageDir)
if err != nil {
return "", fmt.Errorf("resolve update stage dir: %w", err)
}
stageFilename := filepath.Join(stageDir, updateStageETagDir(etag), filename)
if err := ensurePathInDir(stageDir, stageFilename); err != nil {
return "", err
}
return stageFilename, nil
}
func safeUpdateFilename(filename string) (string, error) {
filename = strings.TrimSpace(filename)
if filename == "" {
return "", errors.New("missing update filename")
}
if filename == "." || filename == ".." ||
filepath.IsAbs(filename) || path.IsAbs(filename) ||
strings.ContainsAny(filename, `/\:`) ||
filepath.Base(filename) != filename || path.Base(filename) != filename {
return "", fmt.Errorf("unsafe update filename %q", filename)
}
return filename, nil
}
func updateStageETagDir(etag string) string {
etag = strings.Trim(strings.TrimSpace(etag), "\"")
if etag == "" {
slog.Debug("no etag detected, falling back to filename based dedup")
return "_"
}
sum := sha256.Sum256([]byte(etag))
return hex.EncodeToString(sum[:])
}
func ensurePathInDir(dir, name string) error {
rel, err := filepath.Rel(dir, name)
if err != nil {
return fmt.Errorf("resolve update staging path: %w", err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
return fmt.Errorf("update staging path escapes stage dir: %s", name)
}
return nil
}
func cleanupOldDownloads(stageDir string) {
files, err := os.ReadDir(stageDir)
if err != nil && errors.Is(err, os.ErrNotExist) {
+99 -51
View File
@@ -22,6 +22,15 @@ import (
"golang.org/x/sys/unix"
)
const updateArchiveRoot = "Ollama.app"
type bundleEntryScope int
const (
bundleEntryRelative bundleEntryScope = iota
bundleEntryWithArchiveRoot
)
var (
appBackupDir string
SystemWidePath = "/Applications/Ollama.app"
@@ -167,8 +176,12 @@ func DoUpgrade(interactive bool) error {
}
name := s[1]
if strings.HasSuffix(name, "/") {
d := filepath.Join(BundlePath, name)
err := os.MkdirAll(d, 0o755)
d, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
if err != nil {
anyFailures = true
return err
}
err = os.MkdirAll(d, 0o755)
if err != nil {
anyFailures = true
return fmt.Errorf("failed to mkdir %s: %w", d, err)
@@ -181,30 +194,14 @@ func DoUpgrade(interactive bool) error {
continue
}
src, err := f.Open()
destName, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
if err != nil {
anyFailures = true
return fmt.Errorf("failed to open bundle file %s: %w", name, err)
return err
}
destName := filepath.Join(BundlePath, name)
// Verify directory first
d := filepath.Dir(destName)
if _, err := os.Stat(d); err != nil {
err := os.MkdirAll(d, 0o755)
if err != nil {
anyFailures = true
return fmt.Errorf("failed to mkdir %s: %w", d, err)
}
}
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
if err := extractBundleFile(f, destName, name); err != nil {
anyFailures = true
return fmt.Errorf("failed to open output file %s: %w", destName, err)
}
defer destFile.Close()
if _, err := io.Copy(destFile, src); err != nil {
anyFailures = true
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
return err
}
}
for _, f := range links {
@@ -225,16 +222,24 @@ func DoUpgrade(interactive bool) error {
return err
}
link := string(buf)
if link[0] == '/' {
if link == "" {
anyFailures = true
return fmt.Errorf("bundle contains empty symlink %s", f.Name)
}
if filepath.IsAbs(link) {
anyFailures = true
return fmt.Errorf("bundle contains absolute symlink %s -> %s", f.Name, link)
}
// Don't allow links outside of Ollama.app
if strings.HasPrefix(filepath.Join(filepath.Dir(name), link), "..") {
if !validBundleLinkTarget(name, link, bundleEntryRelative) {
anyFailures = true
return fmt.Errorf("bundle contains link outside of contents %s -> %s", f.Name, link)
return fmt.Errorf("bundle contains invalid symlink %s -> %s", f.Name, link)
}
if err = os.Symlink(link, filepath.Join(BundlePath, name)); err != nil {
destName, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
if err != nil {
anyFailures = true
return err
}
if err = os.Symlink(link, destName); err != nil {
anyFailures = true
return err
}
@@ -282,8 +287,11 @@ func verifyDownload() error {
links := []*zip.File{}
for _, f := range r.File {
if strings.HasSuffix(f.Name, "/") {
d := filepath.Join(dir, f.Name)
err := os.MkdirAll(d, 0o755)
d, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
if err != nil {
return err
}
err = os.MkdirAll(d, 0o755)
if err != nil {
return fmt.Errorf("failed to mkdir %s: %w", d, err)
}
@@ -294,26 +302,12 @@ func verifyDownload() error {
links = append(links, f)
continue
}
src, err := f.Open()
destName, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
if err != nil {
return fmt.Errorf("failed to open bundle file %s: %w", f.Name, err)
return err
}
destName := filepath.Join(dir, f.Name)
// Verify directory first
d := filepath.Dir(destName)
if _, err := os.Stat(d); err != nil {
err := os.MkdirAll(d, 0o755)
if err != nil {
return fmt.Errorf("failed to mkdir %s: %w", d, err)
}
}
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("failed to open output file %s: %w", destName, err)
}
defer destFile.Close()
if _, err := io.Copy(destFile, src); err != nil {
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
if err := extractBundleFile(f, destName, f.Name); err != nil {
return err
}
}
for _, f := range links {
@@ -326,13 +320,20 @@ func verifyDownload() error {
return err
}
link := string(buf)
if link[0] == '/' {
if link == "" {
return fmt.Errorf("bundle contains empty symlink %s", f.Name)
}
if filepath.IsAbs(link) {
return fmt.Errorf("bundle contains absolute symlink %s -> %s", f.Name, link)
}
if strings.HasPrefix(filepath.Join(filepath.Dir(f.Name), link), "..") {
return fmt.Errorf("bundle contains link outside of contents %s -> %s", f.Name, link)
if !validBundleLinkTarget(f.Name, link, bundleEntryWithArchiveRoot) {
return fmt.Errorf("bundle contains invalid symlink %s -> %s", f.Name, link)
}
if err = os.Symlink(link, filepath.Join(dir, f.Name)); err != nil {
destName, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
if err != nil {
return err
}
if err = os.Symlink(link, destName); err != nil {
return err
}
}
@@ -343,6 +344,53 @@ func verifyDownload() error {
return nil
}
func bundleEntryPath(root, name string, scope bundleEntryScope) (string, error) {
cleanName := filepath.Clean(filepath.FromSlash(name))
if !filepath.IsLocal(cleanName) {
return "", fmt.Errorf("bundle contains invalid path: %s", name)
}
if scope == bundleEntryWithArchiveRoot && cleanName != updateArchiveRoot &&
!strings.HasPrefix(cleanName, updateArchiveRoot+string(os.PathSeparator)) {
return "", fmt.Errorf("bundle contains invalid path: %s", name)
}
return filepath.Join(root, cleanName), nil
}
func extractBundleFile(f *zip.File, destName, name string) error {
src, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open bundle file %s: %w", name, err)
}
defer src.Close()
d := filepath.Dir(destName)
if _, err := os.Stat(d); err != nil {
if err := os.MkdirAll(d, 0o755); err != nil {
return fmt.Errorf("failed to mkdir %s: %w", d, err)
}
}
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
if err != nil {
return fmt.Errorf("failed to open output file %s: %w", destName, err)
}
defer destFile.Close()
if _, err := io.Copy(destFile, src); err != nil {
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
}
return nil
}
func validBundleLinkTarget(name, link string, scope bundleEntryScope) bool {
cleanTarget := filepath.Clean(filepath.Join(filepath.Dir(filepath.FromSlash(name)), filepath.FromSlash(link)))
if !filepath.IsLocal(cleanTarget) {
return false
}
return scope == bundleEntryRelative || cleanTarget == updateArchiveRoot ||
strings.HasPrefix(cleanTarget, updateArchiveRoot+string(os.PathSeparator))
}
// If we detect an upgrade bundle, attempt to upgrade at startup
func DoUpgradeAtStartup() error {
bundle := getStagedUpdate()
+68 -3
View File
@@ -2,6 +2,7 @@ package updater
import (
"archive/zip"
"errors"
"io/fs"
"os"
"path/filepath"
@@ -146,6 +147,46 @@ func TestDoUpgrade(t *testing.T) {
}
}
func TestDoUpgradeRejectsInvalidBundlePath(t *testing.T) {
tmpDir := t.TempDir()
BundlePath = filepath.Join(tmpDir, "Ollama.app")
appBackupDir = filepath.Join(tmpDir, "backup")
UpdateStageDir = filepath.Join(tmpDir, "updates")
UpgradeMarkerFile = filepath.Join(tmpDir, "upgraded")
bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip")
invalidTarget := filepath.Join(tmpDir, "invalid-entry")
if err := os.MkdirAll(filepath.Join(BundlePath, "Contents", "MacOS"), 0o755); err != nil {
t.Fatal("failed to create empty dirs")
}
if err := os.WriteFile(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama"), []byte("old app"), 0o755); err != nil {
t.Fatal("failed to create old app")
}
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
t.Fatal("failed to create empty dirs")
}
if err := zipCreationHelper(bundle, []testPayload{{
Name: "Ollama.app/../invalid-entry",
Body: []byte("payload"),
}}); err != nil {
t.Fatal(err)
}
if err := DoUpgrade(false); err == nil {
t.Fatal("expected failure with invalid bundle path")
} else if !strings.Contains(err.Error(), "bundle contains invalid path") {
t.Fatalf("unexpected error with invalid bundle path: %s", err)
}
if _, err := os.Stat(invalidTarget); err == nil {
t.Fatalf("invalid bundle path wrote %s", invalidTarget)
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected stat error for %s: %s", invalidTarget, err)
}
if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama")); err != nil {
t.Fatalf("old app was not restored: %s", err)
}
}
func TestDoUpgradeAtStartup(t *testing.T) {
tmpDir := t.TempDir()
BundlePath = filepath.Join(tmpDir, "Ollama.app")
@@ -203,7 +244,7 @@ func TestVerifyDownloadFailures(t *testing.T) {
in []testPayload
expected string
}{
{"breakout", []testPayload{
{"invalid symlink target", []testPayload{
{
Name: "Ollama.app/",
Body: []byte{},
@@ -212,15 +253,34 @@ func TestVerifyDownloadFailures(t *testing.T) {
Body: []byte("cli payload here"),
}, {
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("../../../../breakout"),
Body: []byte("../../../../invalid-target"),
Mode: os.ModeSymlink,
},
}, "bundle contains link outside"},
}, "bundle contains invalid symlink"},
{"invalid archive symlink target", []testPayload{
{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("../../../invalid-target"),
Mode: os.ModeSymlink,
},
}, "bundle contains invalid symlink"},
{"absolute", []testPayload{{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("/etc/foo"),
Mode: os.ModeSymlink,
}}, "bundle contains absolute"},
{"invalid relative file", []testPayload{{
Name: "Ollama.app/../invalid-entry",
Body: []byte("payload"),
}}, "bundle contains invalid path"},
{"invalid relative directory", []testPayload{{
Name: "Ollama.app/../invalid-entry/",
Body: []byte{},
}}, "bundle contains invalid path"},
{"absolute file", []testPayload{{
Name: filepath.Join(tmpDir, "invalid-entry"),
Body: []byte("payload"),
}}, "bundle contains invalid path"},
{"missing", []testPayload{{
Name: "Ollama.app/Contents/MacOS/Ollama",
Body: []byte("../nothere"),
@@ -242,6 +302,11 @@ func TestVerifyDownloadFailures(t *testing.T) {
if err == nil || !strings.Contains(err.Error(), tt.expected) {
t.Fatalf("expected \"%s\" got %s", tt.expected, err)
}
if _, err := os.Stat(filepath.Join(tmpDir, "invalid-entry")); err == nil {
t.Fatal("invalid bundle path wrote unexpected file")
} else if !errors.Is(err, os.ErrNotExist) {
t.Fatalf("unexpected stat error for invalid file: %s", err)
}
})
}
}
+127
View File
@@ -0,0 +1,127 @@
//go:build (windows || darwin) && updater_live
package updater
import (
"context"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/ollama/ollama/app/store"
"github.com/ollama/ollama/app/version"
)
// TestLiveAppUpdate exercises the production update endpoint and downloads the
// current OS update artifact. It is intentionally excluded from normal test
// runs because it depends on ollama.com and downloads a release artifact.
//
// Run with:
//
// go test -tags updater_live -run TestLiveAppUpdate ./app/updater
func TestLiveAppUpdate(t *testing.T) {
const spoofedVersion = "0.20.0"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
oldUpdateStageDir := UpdateStageDir
oldUpdateDownloaded := UpdateDownloaded
oldVerifyDownload := VerifyDownload
oldVersion := version.Version
defer func() {
UpdateStageDir = oldUpdateStageDir
UpdateDownloaded = oldUpdateDownloaded
VerifyDownload = oldVerifyDownload
version.Version = oldVersion
}()
version.Version = spoofedVersion
expectedFilename := ""
switch runtime.GOOS {
case "windows":
t.Setenv("LOCALAPPDATA", t.TempDir())
expectedFilename = "OllamaSetup.exe"
case "darwin":
expectedFilename = "Ollama-darwin.zip"
default:
t.Fatalf("unsupported updater live test OS %q", runtime.GOOS)
}
UpdateStageDir = filepath.Join(t.TempDir(), "updates")
UpdateDownloaded = false
verifyCalled := false
VerifyDownload = func() error {
verifyCalled = true
return verifyDownload()
}
updater := &Updater{Store: &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}}
defer updater.Store.Close()
available, updateResp := updater.checkForUpdate(ctx)
if !available {
t.Fatalf("expected production update check to offer an update for spoofed version %s", spoofedVersion)
}
if updateResp.UpdateURL == "" {
t.Fatal("production update response did not include a download URL")
}
t.Logf("production update version=%q url=%q", updateResp.UpdateVersion, updateResp.UpdateURL)
if err := updater.DownloadNewRelease(ctx, updateResp); err != nil {
t.Fatalf("download production update: %v", err)
}
staged := getStagedUpdate()
if staged == "" {
t.Fatal("production update was not staged")
}
t.Logf("staged production update at %s", staged)
assertPathInsideDir(t, UpdateStageDir, staged)
if filepath.Base(staged) != expectedFilename {
t.Fatalf("expected staged %s update filename to be %q, got %q", runtime.GOOS, expectedFilename, filepath.Base(staged))
}
expectedExt := filepath.Ext(expectedFilename)
if filepath.Ext(staged) != expectedExt {
t.Fatalf("expected staged %s update to be a %s artifact, got %s", runtime.GOOS, expectedExt, staged)
}
info, err := os.Stat(staged)
if err != nil {
t.Fatalf("stat staged update: %v", err)
}
if info.Size() == 0 {
t.Fatal("staged production update is empty")
}
if !verifyCalled {
t.Fatal("DownloadNewRelease did not call VerifyDownload")
}
t.Logf("production updater download path verified staged %s update", runtime.GOOS)
}
func assertPathInsideDir(t *testing.T, dir, name string) {
t.Helper()
dir, err := filepath.Abs(dir)
if err != nil {
t.Fatal(err)
}
name, err = filepath.Abs(name)
if err != nil {
t.Fatal(err)
}
rel, err := filepath.Rel(dir, name)
if err != nil {
t.Fatal(err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
t.Fatalf("staged update escaped update stage dir: %s", name)
}
}
+265
View File
@@ -11,7 +11,9 @@ import (
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
@@ -19,6 +21,52 @@ import (
"github.com/ollama/ollama/app/store"
)
func TestUpdateStagePathRejectsUnsafeFilename(t *testing.T) {
stageDir := t.TempDir()
for _, tt := range []struct {
name string
filename string
}{
{"empty", ""},
{"dot", "."},
{"dotdot", ".."},
{"posix_parent", "../OllamaSetup.exe"},
{"windows_parent", `..\OllamaSetup.exe`},
{"posix_absolute_tmp", "/tmp/OllamaSetup.exe"},
{"darwin_absolute_app", "/Applications/Ollama.app"},
{"darwin_bundle_path", "Ollama.app/Contents/MacOS/Ollama"},
{"darwin_user_download", "~/Downloads/Ollama-darwin.zip"},
{"windows_absolute", `C:\Users\Public\OllamaSetup.exe`},
{"colon", "Ollama:Setup.exe"},
} {
t.Run(tt.name, func(t *testing.T) {
if _, err := updateStagePath(stageDir, "etag", tt.filename); err == nil {
t.Fatal("expected unsafe filename to be rejected")
}
})
}
}
func TestUpdateStagePathHashesETag(t *testing.T) {
stageDir := t.TempDir()
stageFilename, err := updateStagePath(stageDir, `../escaped`, "OllamaSetup.exe")
if err != nil {
t.Fatal(err)
}
rel, err := filepath.Rel(stageDir, stageFilename)
if err != nil {
t.Fatal(err)
}
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
t.Fatalf("stage filename escaped stage dir: %s", stageFilename)
}
etagDir := filepath.Base(filepath.Dir(stageFilename))
if etagDir == ".." || etagDir == "escaped" || strings.ContainsAny(etagDir, `/\`) {
t.Fatalf("stage filename used raw etag path component: %s", stageFilename)
}
}
func TestIsNewReleaseAvailable(t *testing.T) {
slog.SetLogLoggerLevel(slog.LevelDebug)
var server *httptest.Server
@@ -47,6 +95,223 @@ func TestIsNewReleaseAvailable(t *testing.T) {
}
}
func TestDownloadNewReleaseRejectsUnsafeHeaderFilename(t *testing.T) {
UpdateStageDir = t.TempDir()
oldInstaller := Installer
oldVerifyDownload := VerifyDownload
oldUpdateDownloaded := UpdateDownloaded
defer func() {
Installer = oldInstaller
VerifyDownload = oldVerifyDownload
UpdateDownloaded = oldUpdateDownloaded
}()
Installer = "OllamaSetup.exe"
UpdateDownloaded = false
VerifyDownload = func() error {
t.Fatal("verification should not run for rejected downloads")
return nil
}
var getAttempted atomic.Bool
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
w.Header().Set("ETag", `"safe"`)
w.Header().Set("Content-Disposition", `attachment; filename="../OllamaSetup.exe"`)
w.WriteHeader(http.StatusOK)
return
}
getAttempted.Store(true)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
updater := &Updater{}
err := updater.DownloadNewRelease(t.Context(), UpdateResponse{UpdateURL: server.URL + "/download"})
if err == nil || !strings.Contains(err.Error(), "unsafe update filename") {
t.Fatalf("expected unsafe filename error, got %v", err)
}
if getAttempted.Load() {
t.Fatal("download should not continue after unsafe filename")
}
if _, err := os.Stat(filepath.Join(filepath.Dir(UpdateStageDir), "OllamaSetup.exe")); err == nil {
t.Fatal("download escaped update stage dir")
}
}
func TestDownloadNewReleaseDoesNotUseRawETagAsPathComponent(t *testing.T) {
UpdateStageDir = t.TempDir()
oldInstaller := Installer
oldVerifyDownload := VerifyDownload
oldUpdateDownloaded := UpdateDownloaded
defer func() {
Installer = oldInstaller
VerifyDownload = oldVerifyDownload
UpdateDownloaded = oldUpdateDownloaded
}()
Installer = "OllamaSetup.exe"
UpdateDownloaded = false
VerifyDownload = func() error {
return nil
}
payload := []byte("payload")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("ETag", `"../escaped"`)
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodGet {
_, _ = w.Write(payload)
}
}))
defer server.Close()
updater := &Updater{}
if err := updater.DownloadNewRelease(t.Context(), UpdateResponse{UpdateURL: server.URL + "/download"}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(UpdateStageDir), "escaped", Installer)); err == nil {
t.Fatal("download escaped update stage dir via etag")
}
entries, err := os.ReadDir(UpdateStageDir)
if err != nil {
t.Fatal(err)
}
if len(entries) != 1 {
t.Fatalf("expected one staged update dir, got %d", len(entries))
}
stageFilename := filepath.Join(UpdateStageDir, entries[0].Name(), Installer)
got, err := os.ReadFile(stageFilename)
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatalf("unexpected staged payload %q", got)
}
}
func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
UpdateStageDir = t.TempDir()
oldInstaller := Installer
oldVerifyDownload := VerifyDownload
oldUpdateDownloaded := UpdateDownloaded
oldUpdateCheckInitialDelay := UpdateCheckInitialDelay
oldUpdateCheckInterval := UpdateCheckInterval
oldUpdateCheckURLBase := UpdateCheckURLBase
defer func() {
Installer = oldInstaller
VerifyDownload = oldVerifyDownload
UpdateDownloaded = oldUpdateDownloaded
UpdateCheckInitialDelay = oldUpdateCheckInitialDelay
UpdateCheckInterval = oldUpdateCheckInterval
UpdateCheckURLBase = oldUpdateCheckURLBase
}()
Installer = "OllamaSetup.exe"
UpdateDownloaded = false
UpdateCheckInitialDelay = time.Millisecond
UpdateCheckInterval = 5 * time.Millisecond
var verifyCount atomic.Int32
VerifyDownload = func() error {
verifyCount.Add(1)
return nil
}
headETag := `"old-update"`
getETag := `"download-response-etag"`
payload := []byte("payload")
var headCount atomic.Int32
var getCount atomic.Int32
var server *httptest.Server
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/update.json":
w.Write([]byte(
fmt.Sprintf(`{"version": "9.9.9", "url": "%s"}`,
server.URL+"/9.9.9/"+Installer)))
case "/9.9.9/" + Installer:
w.Header().Set("Content-Disposition", `attachment; filename="OllamaSetup.exe"`)
switch r.Method {
case http.MethodHead:
etag := headETag
if getCount.Load() > 0 {
etag = getETag
}
w.Header().Set("ETag", etag)
headCount.Add(1)
w.WriteHeader(http.StatusOK)
case http.MethodGet:
w.Header().Set("ETag", getETag)
getCount.Add(1)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(payload)
default:
t.Errorf("unexpected request method %s", r.Method)
w.WriteHeader(http.StatusMethodNotAllowed)
}
default:
t.Errorf("unexpected request path %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
UpdateCheckURLBase = server.URL + "/update.json"
updater := &Updater{Store: &store.Store{DBPath: filepath.Join(t.TempDir(), "test.db")}}
defer updater.Store.Close()
settings, err := updater.Store.Settings()
if err != nil {
t.Fatal(err)
}
settings.AutoUpdateEnabled = true
if err := updater.Store.SetSettings(settings); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
callbacks := make(chan string, 4)
updater.StartBackgroundUpdaterChecker(ctx, func(ver string) error {
callbacks <- ver
return nil
})
for range 2 {
select {
case <-callbacks:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for repeated update checks")
}
}
cancel()
stageFilename, err := updateStagePath(UpdateStageDir, getETag, Installer)
if err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(stageFilename)
if err != nil {
t.Fatal(err)
}
if string(got) != string(payload) {
t.Fatalf("unexpected staged payload %q", got)
}
if headCount.Load() < 2 {
t.Fatalf("HEAD count = %d, want at least 2", headCount.Load())
}
if getCount.Load() != 1 {
t.Fatalf("GET count = %d, want 1", getCount.Load())
}
if verifyCount.Load() != 1 {
t.Fatalf("verification count = %d, want 1", verifyCount.Load())
}
if !UpdateDownloaded {
t.Fatal("UpdateDownloaded should stay true for already staged update")
}
}
func TestBackgoundChecker(t *testing.T) {
UpdateStageDir = t.TempDir()
haveUpdate := false
+175
View File
@@ -1,6 +1,7 @@
package updater
import (
"crypto/x509"
"errors"
"fmt"
"log/slog"
@@ -18,6 +19,30 @@ import (
var runningInstaller string
var (
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
procCryptMsgGetParam = crypt32.NewProc("CryptMsgGetParam")
procCryptMsgClose = crypt32.NewProc("CryptMsgClose")
)
const cmsgSignerInfoParam = 6
type cmsgSignerInfo struct {
Version uint32
Issuer windows.CertNameBlob
SerialNumber windows.CryptIntegerBlob
HashAlgorithm windows.CryptAlgorithmIdentifier
HashEncryptionAlgorithm windows.CryptAlgorithmIdentifier
EncryptedHash windows.CryptDataBlob
AuthAttrs cryptAttributes
UnauthAttrs cryptAttributes
}
type cryptAttributes struct {
Count uint32
Attributes unsafe.Pointer
}
type OSVERSIONINFOEXW struct {
dwOSVersionInfoSize uint32
dwMajorVersion uint32
@@ -99,6 +124,12 @@ func DoUpgrade(interactive bool) error {
return fmt.Errorf("failed to lookup downloads")
}
if err := VerifyDownload(); err != nil {
_ = os.Remove(bundle)
slog.Warn("verification failure", "bundle", bundle, "error", err)
return fmt.Errorf("staged update verification failed: %w", err)
}
// We move the installer to ensure we don't race with multiple apps starting in quick succession
if err := os.Rename(bundle, runningInstaller); err != nil {
return fmt.Errorf("unable to rename %s -> %s : %w", bundle, runningInstaller, err)
@@ -184,6 +215,150 @@ func DoPostUpgradeCleanup() error {
}
func verifyDownload() error {
bundle := getStagedUpdate()
if bundle == "" {
return fmt.Errorf("failed to lookup downloads")
}
slog.Debug("verifying update", "bundle", bundle)
if err := verifyWindowsInstallerSignature(bundle); err != nil {
return fmt.Errorf("signature verification failed: %w", err)
}
return nil
}
func verifyWindowsInstallerSignature(filename string) error {
filename16, err := windows.UTF16PtrFromString(filename)
if err != nil {
return err
}
data := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_WHOLECHAIN,
UnionChoice: windows.WTD_CHOICE_FILE,
StateAction: windows.WTD_STATEACTION_VERIFY,
UIContext: windows.WTD_UICONTEXT_INSTALL,
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(&windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: filename16,
}),
}
verifyErr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
data.StateAction = windows.WTD_STATEACTION_CLOSE
closeErr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
if verifyErr != nil {
return verifyErr
}
if closeErr != nil {
return fmt.Errorf("close WinVerifyTrust state: %w", closeErr)
}
subject, err := windowsInstallerSignerSubject(filename)
if err != nil {
return err
}
slog.Debug("verified update signature", "subject", subject)
return nil
}
func windowsInstallerSignerSubject(filename string) (string, error) {
filename16, err := windows.UTF16PtrFromString(filename)
if err != nil {
return "", err
}
var certStore windows.Handle
var msg windows.Handle
if err := windows.CryptQueryObject(
windows.CERT_QUERY_OBJECT_FILE,
unsafe.Pointer(filename16),
windows.CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED,
windows.CERT_QUERY_FORMAT_FLAG_BINARY,
0,
nil,
nil,
nil,
&certStore,
&msg,
nil,
); err != nil {
return "", err
}
defer windows.CertCloseStore(certStore, 0) //nolint:errcheck
defer cryptMsgClose(msg) //nolint:errcheck
var signerInfoSize uint32
if err := cryptMsgGetParam(msg, cmsgSignerInfoParam, 0, nil, &signerInfoSize); err != nil {
return "", err
}
if signerInfoSize == 0 {
return "", fmt.Errorf("missing signer info")
}
signerInfoBuf := make([]byte, signerInfoSize)
if err := cryptMsgGetParam(msg, cmsgSignerInfoParam, 0, unsafe.Pointer(&signerInfoBuf[0]), &signerInfoSize); err != nil {
return "", err
}
signerInfo := (*cmsgSignerInfo)(unsafe.Pointer(&signerInfoBuf[0]))
certInfo := windows.CertInfo{
Issuer: signerInfo.Issuer,
SerialNumber: signerInfo.SerialNumber,
}
cert, err := windows.CertFindCertificateInStore(
certStore,
windows.X509_ASN_ENCODING|windows.PKCS_7_ASN_ENCODING,
0,
windows.CERT_FIND_SUBJECT_CERT,
unsafe.Pointer(&certInfo),
nil,
)
if err != nil {
return "", err
}
defer windows.CertFreeCertificateContext(cert) //nolint:errcheck
parsed, err := x509.ParseCertificate(unsafe.Slice(cert.EncodedCert, cert.Length))
if err != nil {
return "", err
}
for _, org := range parsed.Subject.Organization {
if org == "Ollama Inc." {
return parsed.Subject.String(), nil
}
}
return "", fmt.Errorf("unexpected signer: %s", parsed.Subject.String())
}
func cryptMsgGetParam(msg windows.Handle, paramType, index uint32, data unsafe.Pointer, size *uint32) error {
r1, _, e1 := procCryptMsgGetParam.Call(
uintptr(msg),
uintptr(paramType),
uintptr(index),
uintptr(data),
uintptr(unsafe.Pointer(size)),
)
if r1 == 0 {
if e1 != syscall.Errno(0) {
return e1
}
return syscall.EINVAL
}
return nil
}
func cryptMsgClose(msg windows.Handle) error {
r1, _, e1 := procCryptMsgClose.Call(uintptr(msg))
if r1 == 0 {
if e1 != syscall.Errno(0) {
return e1
}
return syscall.EINVAL
}
return nil
}
+73 -1
View File
@@ -1,13 +1,85 @@
//go:build windows || darwin
//go:build windows
package updater
import (
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
)
func TestVerifyDownloadRejectsUnsignedWindowsInstaller(t *testing.T) {
oldUpdateStageDir := UpdateStageDir
defer func() {
UpdateStageDir = oldUpdateStageDir
}()
t.Setenv("LOCALAPPDATA", t.TempDir())
UpdateStageDir = t.TempDir()
bundle := filepath.Join(UpdateStageDir, "etag", "OllamaSetup.exe")
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(bundle, []byte("not a signed installer"), 0o755); err != nil {
t.Fatal(err)
}
err := verifyDownload()
if err == nil || !strings.Contains(err.Error(), "signature verification failed") {
t.Fatalf("expected signature verification failure, got %v", err)
}
}
func TestDoUpgradeAtStartupRejectsUnsignedWindowsInstaller(t *testing.T) {
oldUpdateStageDir := UpdateStageDir
oldRunningInstaller := runningInstaller
oldUpgradeLogFile := UpgradeLogFile
oldUpgradeMarkerFile := UpgradeMarkerFile
oldVerifyDownload := VerifyDownload
defer func() {
UpdateStageDir = oldUpdateStageDir
runningInstaller = oldRunningInstaller
UpgradeLogFile = oldUpgradeLogFile
UpgradeMarkerFile = oldUpgradeMarkerFile
VerifyDownload = oldVerifyDownload
}()
t.Setenv("LOCALAPPDATA", t.TempDir())
UpdateStageDir = t.TempDir()
runDir := t.TempDir()
runningInstaller = filepath.Join(runDir, "OllamaSetup.exe")
UpgradeLogFile = filepath.Join(runDir, "upgrade.log")
UpgradeMarkerFile = filepath.Join(runDir, "upgraded")
VerifyDownload = verifyDownload
bundle := filepath.Join(UpdateStageDir, "etag", "OllamaSetup.exe")
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(bundle, []byte("not a signed installer"), 0o755); err != nil {
t.Fatal(err)
}
err := DoUpgradeAtStartup()
if err == nil || !strings.Contains(err.Error(), "signature verification failed") {
t.Fatalf("expected signature verification failure, got %v", err)
}
if _, err := os.Stat(runningInstaller); !os.IsNotExist(err) {
t.Fatalf("unsigned installer was moved before verification failed: %v", err)
}
if _, err := os.Stat(bundle); !os.IsNotExist(err) {
t.Fatalf("unsigned staged installer was not removed after verification failure: %v", err)
}
}
func TestIsInstallerRunning(t *testing.T) {
oldInstaller := Installer
defer func() {
Installer = oldInstaller
}()
slog.SetLogLoggerLevel(slog.LevelDebug)
Installer = "go.exe"
if !isInstallerRunning() {
+691
View File
@@ -0,0 +1,691 @@
# Local Ollama superbuild targets.
#
# This file keeps the repository-root CMake project focused on orchestration:
# it builds a runnable local Ollama payload by delegating llama.cpp work to the
# llama/server CMake project and building the Go binary into a matching layout.
include(ExternalProject)
set(OLLAMA_LLAMA_BACKENDS "" CACHE STRING
"Semicolon-separated llama-server GPU backends to build: cuda_v12;cuda_v13;rocm_v7_1;rocm_v7_2;vulkan;cuda_jetpack5;cuda_jetpack6")
set(_ollama_mlx_backends_doc "Semicolon-separated MLX backends to build: cuda_v13;metal_v3;metal_v4")
set(OLLAMA_VERSION "0.0.0" CACHE STRING "Ollama version embedded in the local Go binary")
set(OLLAMA_PAYLOAD_INSTALL_PREFIX "${CMAKE_BINARY_DIR}" CACHE PATH
"Build-time staging prefix for nested Ollama native payloads")
string(REGEX REPLACE "^v" "" OLLAMA_VERSION "${OLLAMA_VERSION}")
set(OLLAMA_NATIVE_CONFIG_ARG)
if(CMAKE_CONFIGURATION_TYPES)
set(OLLAMA_NATIVE_CONFIG_ARG --config Release)
endif()
set(OLLAMA_NATIVE_EXTERNAL_OPTIONS)
if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.28)
list(APPEND OLLAMA_NATIVE_EXTERNAL_OPTIONS BUILD_JOB_SERVER_AWARE TRUE)
endif()
function(ollama_check_metal_toolchain output_version)
find_program(_ollama_xcrun xcrun)
if(NOT _ollama_xcrun)
message(FATAL_ERROR
"MLX Metal requires Xcode command line tools. Install Xcode, run "
"`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, "
"then install the Metal toolchain with "
"`xcodebuild -downloadComponent MetalToolchain`.")
endif()
execute_process(
COMMAND zsh "-c"
"echo \"__METAL_VERSION__\" | \"${_ollama_xcrun}\" -sdk macosx metal -E -x metal -P - 2>/dev/null | tail -1 | tr -d '\n'"
OUTPUT_VARIABLE _metal_version
RESULT_VARIABLE _metal_result)
if(NOT _metal_result EQUAL 0 OR NOT _metal_version MATCHES "^[0-9]+$")
message(FATAL_ERROR
"MLX Metal requires Xcode's Metal toolchain. Install Xcode, run "
"`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, "
"then install the Metal toolchain with "
"`xcodebuild -downloadComponent MetalToolchain`.")
endif()
set(${output_version} "${_metal_version}" PARENT_SCOPE)
endfunction()
function(ollama_macos_major_version output)
execute_process(
COMMAND sw_vers -productVersion
OUTPUT_VARIABLE _macos_version
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE _macos_result
ERROR_QUIET)
if(_macos_result EQUAL 0)
string(REGEX MATCH "^[0-9]+" _macos_major "${_macos_version}")
endif()
set(${output} "${_macos_major}" PARENT_SCOPE)
endfunction()
function(ollama_macos_sdk_major_version output)
execute_process(
COMMAND xcrun --sdk macosx --show-sdk-version
OUTPUT_VARIABLE _sdk_version
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE _sdk_result
ERROR_QUIET)
if(_sdk_result EQUAL 0)
string(REGEX MATCH "^[0-9]+" _sdk_major "${_sdk_version}")
endif()
set(${output} "${_sdk_major}" PARENT_SCOPE)
endfunction()
function(ollama_default_mlx_backends output)
set(_backends "")
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
ollama_check_metal_toolchain(_metal_version)
ollama_macos_major_version(_macos_major)
ollama_macos_sdk_major_version(_sdk_major)
if(_macos_major AND _sdk_major AND _macos_major GREATER_EQUAL 26 AND _sdk_major GREATER_EQUAL 26)
set(_backends "metal_v4")
else()
set(_backends "metal_v3")
endif()
message(STATUS "Defaulting OLLAMA_MLX_BACKENDS=${_backends} for macOS arm64")
endif()
set(${output} "${_backends}" PARENT_SCOPE)
endfunction()
if(NOT DEFINED OLLAMA_MLX_BACKENDS)
ollama_default_mlx_backends(_ollama_default_mlx_backends)
set(OLLAMA_MLX_BACKENDS "${_ollama_default_mlx_backends}" CACHE STRING "${_ollama_mlx_backends_doc}")
else()
set(OLLAMA_MLX_BACKENDS "${OLLAMA_MLX_BACKENDS}" CACHE STRING "${_ollama_mlx_backends_doc}")
endif()
if(NOT OLLAMA_HAVE_LLAMA_SERVER)
if(OLLAMA_LLAMA_BACKENDS)
message(FATAL_ERROR "llama/server is required when OLLAMA_LLAMA_BACKENDS is set")
endif()
if(NOT OLLAMA_MLX_BACKENDS)
message(FATAL_ERROR "llama/server is required for local Ollama builds")
endif()
else()
file(READ "${CMAKE_SOURCE_DIR}/LLAMA_CPP_VERSION" OLLAMA_LLAMA_CPP_GIT_TAG)
string(STRIP "${OLLAMA_LLAMA_CPP_GIT_TAG}" OLLAMA_LLAMA_CPP_GIT_TAG)
include(${CMAKE_SOURCE_DIR}/llama/compat/compat.cmake)
if(DEFINED FETCHCONTENT_SOURCE_DIR_LLAMA_CPP AND NOT "${FETCHCONTENT_SOURCE_DIR_LLAMA_CPP}" STREQUAL "")
get_filename_component(OLLAMA_LLAMA_CPP_SOURCE_DIR
"${FETCHCONTENT_SOURCE_DIR_LLAMA_CPP}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
message(STATUS "Using llama.cpp source override: ${OLLAMA_LLAMA_CPP_SOURCE_DIR}")
add_custom_target(ollama-llama-cpp-source)
elseif(DEFINED ENV{OLLAMA_LLAMA_CPP_SOURCE})
get_filename_component(OLLAMA_LLAMA_CPP_SOURCE_DIR
"$ENV{OLLAMA_LLAMA_CPP_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
message(STATUS "Using local llama.cpp source: ${OLLAMA_LLAMA_CPP_SOURCE_DIR}")
add_custom_target(ollama-llama-cpp-source)
else()
set(OLLAMA_LLAMA_CPP_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/llama_cpp-src")
ExternalProject_Add(ollama-llama-cpp-source
GIT_REPOSITORY "https://github.com/ggml-org/llama.cpp.git"
GIT_TAG ${OLLAMA_LLAMA_CPP_GIT_TAG}
GIT_SHALLOW TRUE
SOURCE_DIR ${OLLAMA_LLAMA_CPP_SOURCE_DIR}
CONFIGURE_COMMAND ""
BUILD_COMMAND ""
INSTALL_COMMAND ""
PATCH_COMMAND ${OLLAMA_LLAMA_CPP_COMPAT_PATCH_COMMAND}
USES_TERMINAL_DOWNLOAD TRUE
USES_TERMINAL_PATCH TRUE)
endif()
endif()
set(_mlx_source_targets)
if(OLLAMA_MLX_BACKENDS)
file(READ "${CMAKE_SOURCE_DIR}/MLX_VERSION" OLLAMA_MLX_GIT_TAG)
string(STRIP "${OLLAMA_MLX_GIT_TAG}" OLLAMA_MLX_GIT_TAG)
file(READ "${CMAKE_SOURCE_DIR}/MLX_C_VERSION" OLLAMA_MLX_C_GIT_TAG)
string(STRIP "${OLLAMA_MLX_C_GIT_TAG}" OLLAMA_MLX_C_GIT_TAG)
if(DEFINED FETCHCONTENT_SOURCE_DIR_MLX AND NOT "${FETCHCONTENT_SOURCE_DIR_MLX}" STREQUAL "")
get_filename_component(OLLAMA_MLX_SOURCE_DIR
"${FETCHCONTENT_SOURCE_DIR_MLX}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
message(STATUS "Using MLX source override: ${OLLAMA_MLX_SOURCE_DIR}")
elseif(DEFINED ENV{OLLAMA_MLX_SOURCE})
get_filename_component(OLLAMA_MLX_SOURCE_DIR
"$ENV{OLLAMA_MLX_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
message(STATUS "Using local MLX source: ${OLLAMA_MLX_SOURCE_DIR}")
else()
set(OLLAMA_MLX_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/mlx-src")
ExternalProject_Add(ollama-mlx-source
GIT_REPOSITORY "https://github.com/ml-explore/mlx.git"
GIT_TAG ${OLLAMA_MLX_GIT_TAG}
# MLX uses commit hashes while we track closely; switch to shallow when MLX pins move to tags.
GIT_SHALLOW FALSE
SOURCE_DIR ${OLLAMA_MLX_SOURCE_DIR}
CONFIGURE_COMMAND ""
BUILD_COMMAND ""
INSTALL_COMMAND ""
USES_TERMINAL_DOWNLOAD TRUE)
list(APPEND _mlx_source_targets ollama-mlx-source)
endif()
if(DEFINED "FETCHCONTENT_SOURCE_DIR_MLX-C" AND NOT "${FETCHCONTENT_SOURCE_DIR_MLX-C}" STREQUAL "")
get_filename_component(OLLAMA_MLX_C_SOURCE_DIR
"${FETCHCONTENT_SOURCE_DIR_MLX-C}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
message(STATUS "Using MLX-C source override: ${OLLAMA_MLX_C_SOURCE_DIR}")
elseif(DEFINED ENV{OLLAMA_MLX_C_SOURCE})
get_filename_component(OLLAMA_MLX_C_SOURCE_DIR
"$ENV{OLLAMA_MLX_C_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
message(STATUS "Using local MLX-C source: ${OLLAMA_MLX_C_SOURCE_DIR}")
else()
set(OLLAMA_MLX_C_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/mlx-c-src")
ExternalProject_Add(ollama-mlx-c-source
GIT_REPOSITORY "https://github.com/ml-explore/mlx-c.git"
GIT_TAG ${OLLAMA_MLX_C_GIT_TAG}
# MLX-C uses commit hashes while we track closely; switch to shallow when MLX-C pins move to tags.
GIT_SHALLOW FALSE
SOURCE_DIR ${OLLAMA_MLX_C_SOURCE_DIR}
CONFIGURE_COMMAND ""
BUILD_COMMAND ""
INSTALL_COMMAND ""
USES_TERMINAL_DOWNLOAD TRUE)
list(APPEND _mlx_source_targets ollama-mlx-c-source)
endif()
add_custom_target(ollama-mlx-sources DEPENDS ${_mlx_source_targets})
endif()
set(OLLAMA_NATIVE_BUILD_TOOL_COMMAND
${CMAKE_COMMAND} --build <BINARY_DIR>)
set(OLLAMA_NATIVE_BUILD_TARGET_ARG --target)
if(CMAKE_GENERATOR MATCHES "Makefiles")
set(OLLAMA_NATIVE_BUILD_TOOL_COMMAND
"$(MAKE)" -C <BINARY_DIR>)
set(OLLAMA_NATIVE_BUILD_TARGET_ARG)
endif()
function(ollama_escape_cmake_list input output)
string(REPLACE ";" "|" _escaped "${input}")
set(${output} "${_escaped}" PARENT_SCOPE)
endfunction()
function(ollama_collect_cache_args_with_prefix prefix output)
get_cmake_property(_cache_variables CACHE_VARIABLES)
list(SORT _cache_variables)
set(_args)
foreach(_var IN LISTS _cache_variables)
if(_var MATCHES "^${prefix}")
ollama_escape_cmake_list("${${_var}}" _value)
list(APPEND _args "-D${_var}=${_value}")
endif()
endforeach()
set(${output} "${_args}" PARENT_SCOPE)
endfunction()
function(ollama_append_cache_arg_if_set output name)
if(DEFINED ${name} AND NOT "${${name}}" STREQUAL "")
ollama_escape_cmake_list("${${name}}" _value)
set(${output} ${${output}} "-D${name}=${_value}" PARENT_SCOPE)
endif()
endfunction()
function(ollama_cache_arg_is_set name output)
if(DEFINED ${name} AND NOT "${${name}}" STREQUAL "")
set(${output} TRUE PARENT_SCOPE)
else()
set(${output} FALSE PARENT_SCOPE)
endif()
endfunction()
function(ollama_llama_cuda_preset backend output)
ollama_cache_arg_is_set(CMAKE_CUDA_ARCHITECTURES _has_cuda_arch)
if(_has_cuda_arch)
set(_preset "llama_${backend}_user_arch")
elseif(WIN32)
set(_preset "llama_${backend}_windows")
else()
set(_preset "llama_${backend}_linux")
endif()
set(${output} "${_preset}" PARENT_SCOPE)
endfunction()
function(ollama_mlx_cuda_preset output)
ollama_cache_arg_is_set(MLX_CUDA_ARCHITECTURES _has_mlx_arch)
ollama_cache_arg_is_set(CMAKE_CUDA_ARCHITECTURES _has_cuda_arch)
if(_has_mlx_arch OR _has_cuda_arch)
set(_preset "mlx_cuda_v13_user_arch")
elseif(WIN32)
set(_preset "mlx_cuda_v13_windows")
else()
set(_preset "mlx_cuda_v13_linux")
endif()
set(${output} "${_preset}" PARENT_SCOPE)
endfunction()
function(ollama_rocm_preset backend output)
ollama_cache_arg_is_set(AMDGPU_TARGETS _has_amdgpu_targets)
ollama_cache_arg_is_set(CMAKE_HIP_ARCHITECTURES _has_hip_arch)
if(_has_amdgpu_targets OR _has_hip_arch)
if(backend STREQUAL "rocm_v7_1" AND NOT WIN32)
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_1 is only supported for Windows ROCm builds")
elseif(backend STREQUAL "rocm_v7_2" AND WIN32)
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_2 is only supported for Linux ROCm builds")
endif()
elseif(backend STREQUAL "rocm_v7_1")
if(NOT WIN32)
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_1 is only supported for Windows ROCm builds")
endif()
set(_preset "${backend}_windows")
elseif(backend STREQUAL "rocm_v7_2")
if(WIN32)
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_2 is only supported for Linux ROCm builds")
endif()
set(_preset "${backend}_linux")
else()
message(FATAL_ERROR "Unknown ROCm backend '${backend}'")
endif()
if(_has_amdgpu_targets OR _has_hip_arch)
set(_preset "${backend}_user_arch")
endif()
set(${output} "${_preset}" PARENT_SCOPE)
endfunction()
function(ollama_add_llama_server_build name)
cmake_parse_arguments(ARG "" "PRESET;RUNNER_DIR" "TARGETS;CMAKE_ARGS" ${ARGN})
if(NOT ARG_TARGETS)
message(FATAL_ERROR "ollama_add_llama_server_build(${name}) requires TARGETS")
endif()
if(WIN32 AND name STREQUAL "vulkan")
# The Vulkan shader generator nests deeply enough to hit Windows MAX_PATH.
set(_build_dir ${CMAKE_BINARY_DIR}/ls-vk)
else()
set(_build_dir ${CMAKE_BINARY_DIR}/llama-server-${name})
endif()
ollama_collect_cache_args_with_prefix("GGML_" _ggml_cache_args)
ollama_collect_cache_args_with_prefix("LLAMA_" _llama_cache_args)
set(_cmake_args
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
-DCMAKE_INSTALL_PREFIX=${OLLAMA_PAYLOAD_INSTALL_PREFIX}
-DOLLAMA_LIB_DIR:STRING=${OLLAMA_LIB_DIR}
-DOLLAMA_RUNNER_DIR=${ARG_RUNNER_DIR}
-DFETCHCONTENT_SOURCE_DIR_LLAMA_CPP=${OLLAMA_LLAMA_CPP_SOURCE_DIR}
-DOLLAMA_LLAMA_CPP_SKIP_COMPAT_PATCH=ON
-DGGML_NATIVE=OFF
-DGGML_OPENMP=OFF
${ARG_CMAKE_ARGS}
${_ggml_cache_args}
${_llama_cache_args}
)
if(APPLE)
if(CMAKE_OSX_ARCHITECTURES)
list(APPEND _cmake_args
-DCMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES})
endif()
if(CMAKE_OSX_DEPLOYMENT_TARGET)
list(APPEND _cmake_args
-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET})
endif()
endif()
set(_configure_command ${CMAKE_COMMAND}
-S ${CMAKE_SOURCE_DIR}/llama/server
-B <BINARY_DIR>
${_cmake_args})
if(ARG_PRESET)
set(_configure_command ${CMAKE_COMMAND}
-S ${CMAKE_SOURCE_DIR}/llama/server
--preset ${ARG_PRESET}
-B <BINARY_DIR>
${_cmake_args})
endif()
ExternalProject_Add(ollama-llama-server-${name}
SOURCE_DIR ${CMAKE_SOURCE_DIR}/llama/server
BINARY_DIR ${_build_dir}
CONFIGURE_COMMAND ${_configure_command}
BUILD_COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
${OLLAMA_NATIVE_CONFIG_ARG}
${OLLAMA_NATIVE_BUILD_TARGET_ARG} ${ARG_TARGETS}
INSTALL_COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
${OLLAMA_NATIVE_CONFIG_ARG}
--component llama-server
DEPENDS ollama-llama-cpp-source
LIST_SEPARATOR |
# ExternalProject cannot reliably infer when nested FetchContent
# sources, compat patches, or forwarded GGML/LLAMA cache settings need
# a rebuild. Always entering the sub-build keeps direct `cmake --build`
# iteration correct; the nested generator still performs incremental
# compilation.
BUILD_ALWAYS TRUE
${OLLAMA_NATIVE_EXTERNAL_OPTIONS}
USES_TERMINAL_CONFIGURE TRUE
USES_TERMINAL_BUILD TRUE
USES_TERMINAL_INSTALL TRUE)
endfunction()
function(ollama_add_mlx_build name)
cmake_parse_arguments(ARG "" "PRESET;RUNNER_DIR" "CMAKE_ARGS" ${ARGN})
if(NOT ARG_RUNNER_DIR)
message(FATAL_ERROR "ollama_add_mlx_build(${name}) requires RUNNER_DIR")
endif()
set(_build_dir ${CMAKE_BINARY_DIR}/${ARG_RUNNER_DIR})
ollama_collect_cache_args_with_prefix("MLX_" _mlx_cache_args)
set(_cmake_args
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
-DCMAKE_INSTALL_PREFIX=${OLLAMA_PAYLOAD_INSTALL_PREFIX}
-DOLLAMA_LIB_DIR:STRING=${OLLAMA_LIB_DIR}
-DOLLAMA_RUNNER_DIR=${ARG_RUNNER_DIR}
-DOLLAMA_SOURCE_DIR=${CMAKE_SOURCE_DIR}
-DFETCHCONTENT_SOURCE_DIR_MLX=${OLLAMA_MLX_SOURCE_DIR}
-DFETCHCONTENT_SOURCE_DIR_MLX-C=${OLLAMA_MLX_C_SOURCE_DIR}
-DOLLAMA_MLX_GENERATE_WRAPPERS=OFF
${ARG_CMAKE_ARGS}
${_mlx_cache_args}
)
foreach(_arg IN ITEMS
BLAS_INCLUDE_DIRS
LAPACK_INCLUDE_DIRS
CUDAToolkit_ROOT
CUDNN_ROOT_DIR
CUDNN_INCLUDE_PATH
CUDNN_LIBRARY_PATH
CMAKE_CUDA_COMPILER
CMAKE_CUDA_HOST_COMPILER
CMAKE_INCLUDE_PATH
CMAKE_LIBRARY_PATH
CMAKE_PREFIX_PATH)
ollama_append_cache_arg_if_set(_cmake_args ${_arg})
endforeach()
if(APPLE)
if(CMAKE_OSX_ARCHITECTURES)
list(APPEND _cmake_args
-DCMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES})
endif()
endif()
set(_configure_command ${CMAKE_COMMAND}
-S ${CMAKE_SOURCE_DIR}/cmake/mlx
-B <BINARY_DIR>
${_cmake_args})
if(ARG_PRESET)
set(_configure_command ${CMAKE_COMMAND}
-S ${CMAKE_SOURCE_DIR}/cmake/mlx
--preset ${ARG_PRESET}
-B <BINARY_DIR>
${_cmake_args})
endif()
ExternalProject_Add(ollama-mlx-${name}
SOURCE_DIR ${CMAKE_SOURCE_DIR}/cmake/mlx
BINARY_DIR ${_build_dir}
CONFIGURE_COMMAND ${_configure_command}
BUILD_COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
${OLLAMA_NATIVE_CONFIG_ARG}
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlx
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlxc
INSTALL_COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
${OLLAMA_NATIVE_CONFIG_ARG}
--component MLX
COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
${OLLAMA_NATIVE_CONFIG_ARG}
--component MLX_VENDOR
DEPENDS ollama-mlx-sources
LIST_SEPARATOR |
BUILD_ALWAYS TRUE
${OLLAMA_NATIVE_EXTERNAL_OPTIONS}
USES_TERMINAL_CONFIGURE TRUE
USES_TERMINAL_BUILD TRUE
USES_TERMINAL_INSTALL TRUE)
endfunction()
find_program(GO_EXECUTABLE go)
if(OLLAMA_MLX_BACKENDS)
set(_mlx_c_headers_dir "${OLLAMA_MLX_C_SOURCE_DIR}/mlx/c")
set(_mlx_c_headers_dest "${CMAKE_SOURCE_DIR}/x/mlxrunner/mlx/include/mlx/c")
if(GO_EXECUTABLE AND (NOT APPLE OR CMAKE_SYSTEM_PROCESSOR STREQUAL CMAKE_HOST_SYSTEM_PROCESSOR))
add_custom_target(ollama-mlx-generate-wrappers
COMMAND ${CMAKE_COMMAND}
-DMLX_C_HEADERS_DIR=${_mlx_c_headers_dir}
-DMLX_C_HEADERS_DEST=${_mlx_c_headers_dest}
-P "${CMAKE_SOURCE_DIR}/cmake/vendor-mlx-c-headers.cmake"
COMMAND ${CMAKE_COMMAND} -E env
CC= CGO_CFLAGS= CGO_CXXFLAGS=
${GO_EXECUTABLE} generate ./x/...
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
DEPENDS ollama-mlx-sources
COMMENT "Regenerating MLX Go wrappers"
VERBATIM)
else()
add_custom_target(ollama-mlx-generate-wrappers
COMMAND ${CMAKE_COMMAND} -E echo
"Cannot regenerate MLX wrappers while Go is unavailable or while cross-compiling"
COMMAND ${CMAKE_COMMAND} -E false
DEPENDS ollama-mlx-sources
VERBATIM)
endif()
endif()
if(OLLAMA_HAVE_LLAMA_SERVER)
if(NOT OLLAMA_GO_OUTPUT)
if(WIN32)
set(OLLAMA_GO_OUTPUT ${CMAKE_SOURCE_DIR}/ollama.exe)
else()
set(OLLAMA_GO_OUTPUT ${CMAKE_SOURCE_DIR}/ollama)
endif()
endif()
if(NOT IS_ABSOLUTE "${OLLAMA_GO_OUTPUT}")
set(OLLAMA_GO_OUTPUT "${CMAKE_SOURCE_DIR}/${OLLAMA_GO_OUTPUT}")
endif()
get_filename_component(OLLAMA_GO_OUTPUT "${OLLAMA_GO_OUTPUT}" ABSOLUTE)
set(OLLAMA_GO_OUTPUT "${OLLAMA_GO_OUTPUT}" CACHE FILEPATH "Output path for the local Ollama Go binary")
get_filename_component(OLLAMA_GO_OUTPUT_DIR "${OLLAMA_GO_OUTPUT}" DIRECTORY)
set(OLLAMA_GO_LDFLAGS
"-s -w -X=github.com/ollama/ollama/version.Version=${OLLAMA_VERSION} -X=github.com/ollama/ollama/server.mode=release")
if(GO_EXECUTABLE)
add_custom_target(ollama-go ALL
COMMAND ${CMAKE_COMMAND} -E make_directory "${OLLAMA_GO_OUTPUT_DIR}"
COMMAND ${CMAKE_COMMAND} -E env CGO_ENABLED=1
${GO_EXECUTABLE} build -trimpath -ldflags "${OLLAMA_GO_LDFLAGS}" -o "${OLLAMA_GO_OUTPUT}" .
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
BYPRODUCTS ${OLLAMA_GO_OUTPUT}
COMMENT "Building Ollama Go binary"
VERBATIM)
else()
add_custom_target(ollama-go ALL
COMMAND ${CMAKE_COMMAND} -E echo
"Go executable not found. Install Go or set GO_EXECUTABLE to build the local Ollama binary."
COMMAND ${CMAKE_COMMAND} -E false
COMMENT "Building Ollama Go binary"
VERBATIM)
endif()
set(_cpu_args)
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
list(APPEND _cpu_args
-DBUILD_SHARED_LIBS=OFF
-DGGML_BACKEND_DL=OFF
-DGGML_METAL=ON
-DGGML_METAL_EMBED_LIBRARY=ON)
else()
list(APPEND _cpu_args
-DBUILD_SHARED_LIBS=ON
-DGGML_BACKEND_DL=ON
-DGGML_CPU_ALL_VARIANTS=ON)
if(WIN32)
list(APPEND _cpu_args -DGGML_OPENMP=ON)
endif()
if(APPLE)
list(APPEND _cpu_args -DGGML_METAL=OFF)
endif()
endif()
ollama_add_llama_server_build(local
RUNNER_DIR ""
TARGETS llama-server llama-quantize
CMAKE_ARGS ${_cpu_args})
add_custom_target(ollama-local ALL
DEPENDS ollama-go ollama-llama-server-local
COMMENT "Building local Ollama payload")
install(PROGRAMS "${OLLAMA_GO_OUTPUT}"
DESTINATION "${CMAKE_INSTALL_BINDIR}"
COMPONENT ollama-local)
endif()
set(_backend_targets)
if(OLLAMA_HAVE_LLAMA_SERVER)
foreach(_backend IN LISTS OLLAMA_LLAMA_BACKENDS)
if(_backend STREQUAL "cuda_v12")
ollama_llama_cuda_preset(${_backend} _cuda_preset)
set(_cuda_args)
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
ollama_add_llama_server_build(${_backend}
PRESET ${_cuda_preset}
RUNNER_DIR ${_backend}
TARGETS ggml-cuda
CMAKE_ARGS ${_cuda_args})
list(APPEND _backend_targets ollama-llama-server-${_backend})
elseif(_backend STREQUAL "cuda_v13")
ollama_llama_cuda_preset(${_backend} _cuda_preset)
set(_cuda_args)
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
ollama_add_llama_server_build(${_backend}
PRESET ${_cuda_preset}
RUNNER_DIR ${_backend}
TARGETS ggml-cuda
CMAKE_ARGS ${_cuda_args})
list(APPEND _backend_targets ollama-llama-server-${_backend})
elseif(_backend STREQUAL "rocm_v7_1" OR _backend STREQUAL "rocm_v7_2")
# ROCm 7.1 and 7.2 currently share build settings. Keep the backend
# names versioned so future packaging can install side-by-side ROCm
# payloads without changing the superbuild interface.
ollama_rocm_preset(${_backend} _rocm_preset)
set(_rocm_args
-DBUILD_SHARED_LIBS=ON
-DGGML_BACKEND_DL=ON
-DGGML_HIP=ON
-DCMAKE_HIP_PLATFORM=amd
-DOLLAMA_GPU_BACKEND=hip)
ollama_append_cache_arg_if_set(_rocm_args AMDGPU_TARGETS)
ollama_append_cache_arg_if_set(_rocm_args CMAKE_HIP_ARCHITECTURES)
ollama_append_cache_arg_if_set(_rocm_args CMAKE_HIP_FLAGS)
ollama_append_cache_arg_if_set(_rocm_args CMAKE_PREFIX_PATH)
ollama_add_llama_server_build(${_backend}
PRESET ${_rocm_preset}
RUNNER_DIR ${_backend}
TARGETS ggml-hip
CMAKE_ARGS ${_rocm_args})
list(APPEND _backend_targets ollama-llama-server-${_backend})
elseif(_backend STREQUAL "vulkan")
ollama_add_llama_server_build(vulkan
RUNNER_DIR vulkan
TARGETS ggml-vulkan
CMAKE_ARGS
-DBUILD_SHARED_LIBS=ON
-DGGML_BACKEND_DL=ON
-DGGML_VULKAN=ON
-DOLLAMA_GPU_BACKEND=vulkan)
list(APPEND _backend_targets ollama-llama-server-vulkan)
elseif(_backend STREQUAL "cuda_jetpack5")
if(CMAKE_CUDA_ARCHITECTURES)
set(_cuda_preset llama_cuda_jetpack5_user_arch)
else()
set(_cuda_preset llama_cuda_jetpack5)
endif()
set(_cuda_args)
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
ollama_add_llama_server_build(${_backend}
PRESET ${_cuda_preset}
RUNNER_DIR ${_backend}
TARGETS ggml-cuda
CMAKE_ARGS ${_cuda_args})
list(APPEND _backend_targets ollama-llama-server-${_backend})
elseif(_backend STREQUAL "cuda_jetpack6")
if(CMAKE_CUDA_ARCHITECTURES)
set(_cuda_preset llama_cuda_jetpack6_user_arch)
else()
set(_cuda_preset llama_cuda_jetpack6)
endif()
set(_cuda_args)
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
ollama_add_llama_server_build(${_backend}
PRESET ${_cuda_preset}
RUNNER_DIR ${_backend}
TARGETS ggml-cuda
CMAKE_ARGS ${_cuda_args})
list(APPEND _backend_targets ollama-llama-server-${_backend})
else()
message(FATAL_ERROR
"Unknown OLLAMA_LLAMA_BACKENDS entry '${_backend}'")
endif()
endforeach()
endif()
if(_backend_targets)
add_custom_target(ollama-llama-server-backends ALL
DEPENDS ${_backend_targets}
COMMENT "Building llama-server GPU backends")
endif()
set(_mlx_targets)
foreach(_backend IN LISTS OLLAMA_MLX_BACKENDS)
if(_backend STREQUAL "cuda_v13")
ollama_mlx_cuda_preset(_mlx_cuda_preset)
set(_mlx_cuda_args)
ollama_append_cache_arg_if_set(_mlx_cuda_args CMAKE_CUDA_ARCHITECTURES)
ollama_append_cache_arg_if_set(_mlx_cuda_args MLX_CUDA_ARCHITECTURES)
ollama_append_cache_arg_if_set(_mlx_cuda_args CMAKE_CUDA_FLAGS)
ollama_add_mlx_build(cuda_v13
PRESET ${_mlx_cuda_preset}
RUNNER_DIR mlx_cuda_v13
CMAKE_ARGS ${_mlx_cuda_args})
list(APPEND _mlx_targets ollama-mlx-cuda_v13)
elseif(_backend STREQUAL "metal_v3")
if(NOT APPLE)
message(FATAL_ERROR "OLLAMA_MLX_BACKENDS=metal_v3 is only supported on macOS")
endif()
ollama_check_metal_toolchain(_metal_version)
ollama_add_mlx_build(metal_v3
PRESET mlx_metal_v3
RUNNER_DIR mlx_metal_v3)
list(APPEND _mlx_targets ollama-mlx-metal_v3)
elseif(_backend STREQUAL "metal_v4")
if(NOT APPLE)
message(FATAL_ERROR "OLLAMA_MLX_BACKENDS=metal_v4 is only supported on macOS")
endif()
ollama_check_metal_toolchain(_metal_version)
ollama_macos_sdk_major_version(_ollama_mlx_sdk_major)
if(_ollama_mlx_sdk_major AND _ollama_mlx_sdk_major GREATER_EQUAL 26)
ollama_add_mlx_build(metal_v4
PRESET mlx_metal_v4
RUNNER_DIR mlx_metal_v4)
list(APPEND _mlx_targets ollama-mlx-metal_v4)
else()
message(FATAL_ERROR
"OLLAMA_MLX_BACKENDS=metal_v4 requires the macOS 26 SDK. "
"Install a newer Xcode or use OLLAMA_MLX_BACKENDS=metal_v3.")
endif()
else()
message(FATAL_ERROR
"Unknown OLLAMA_MLX_BACKENDS entry '${_backend}'")
endif()
endforeach()
if(_mlx_targets)
add_custom_target(ollama-mlx-backends ALL
DEPENDS ${_mlx_targets}
COMMENT "Building MLX backends")
endif()
install(DIRECTORY "${OLLAMA_PAYLOAD_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}/"
DESTINATION "${OLLAMA_LIB_DIR}"
COMPONENT ollama-local
USE_SOURCE_PERMISSIONS)
+235
View File
@@ -0,0 +1,235 @@
cmake_minimum_required(VERSION 3.24)
project(OllamaMLX C CXX)
include(CheckLanguage)
include(GNUInstallDirs)
find_package(Threads REQUIRED)
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
endif()
if(NOT DEFINED BUILD_SHARED_LIBS)
set(BUILD_SHARED_LIBS ON)
endif()
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS ON)
if(APPLE)
set(CMAKE_BUILD_RPATH "@loader_path")
set(CMAKE_INSTALL_RPATH "@loader_path")
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
endif()
if(NOT DEFINED OLLAMA_SOURCE_DIR OR "${OLLAMA_SOURCE_DIR}" STREQUAL "")
get_filename_component(OLLAMA_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE)
endif()
get_filename_component(OLLAMA_SOURCE_DIR "${OLLAMA_SOURCE_DIR}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_LIST_DIR}")
set(OLLAMA_SOURCE_DIR "${OLLAMA_SOURCE_DIR}" CACHE PATH "Ollama repository root")
set(OLLAMA_LIB_DIR "lib/ollama" CACHE STRING "Install destination for Ollama runtime payloads")
set(OLLAMA_RUNNER_DIR "" CACHE STRING "Ollama runtime payload subdirectory")
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
set(OLLAMA_INSTALL_DIR ${OLLAMA_LIB_DIR}/${OLLAMA_RUNNER_DIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
if(MLX_CUDA_ARCHITECTURES OR CMAKE_CUDA_ARCHITECTURES)
check_language(CUDA)
endif()
option(OLLAMA_MLX_GENERATE_WRAPPERS "Regenerate MLX Go wrappers" OFF)
message(STATUS "Setting up MLX (this takes a while...)")
add_subdirectory(${OLLAMA_SOURCE_DIR}/x/imagegen/mlx ${CMAKE_BINARY_DIR}/x/imagegen/mlx)
# Find CUDA toolkit if MLX is built with CUDA support.
find_package(CUDAToolkit)
# Build list of directories for runtime dependency resolution.
set(MLX_RUNTIME_DIRS ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR})
# Add cuDNN bin paths for DLLs (Windows MLX CUDA builds).
# CUDNN_ROOT_DIR is the standard CMake variable for cuDNN location.
if(CUDNN_ROOT_DIR)
set(_cudnn_root "${CUDNN_ROOT_DIR}")
elseif(DEFINED ENV{CUDNN_ROOT_DIR})
set(_cudnn_root "$ENV{CUDNN_ROOT_DIR}")
endif()
if(_cudnn_root)
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/).
file(GLOB CUDNN_BIN_SUBDIRS "${_cudnn_root}/bin/*")
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
endif()
# Add build output directory and MLX dependency build directories.
list(APPEND MLX_RUNTIME_DIRS ${OLLAMA_BUILD_DIR})
# OpenBLAS DLL location (pre-built zip extracts into openblas-src/bin/).
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
# NCCL: on Linux, if real NCCL is found, cmake bundles libnccl.so via the
# regex below. If NCCL is not found, MLX links a static stub (OBJECT lib)
# so there is no runtime dependency. This path covers the stub build dir
# for windows so we include the DLL in our dependencies.
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/distributed/nccl/nccl_stub-prefix/src/nccl_stub-build/Release)
# Base regexes for runtime dependencies (cross-platform).
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl openblas gfortran)
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer).
if(WIN32)
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
endif()
# Keep mlx/mlxc targets separate from runtime dependencies so --strip only
# applies to the binaries we build, not vendor DLLs/libs.
install(TARGETS mlx mlxc
RUNTIME_DEPENDENCY_SET mlx_runtime_deps
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
)
install(RUNTIME_DEPENDENCY_SET mlx_runtime_deps
DIRECTORIES ${MLX_RUNTIME_DIRS}
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
PRE_EXCLUDE_REGEXES ".*"
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
)
if(TARGET jaccl)
install(TARGETS jaccl
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
)
endif()
# Install the Metal library for macOS arm64 (must be colocated with the binary).
# Metal backend is only built for arm64, not x86_64.
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
DESTINATION ${OLLAMA_INSTALL_DIR}
COMPONENT MLX)
endif()
# Install headers for NVRTC JIT compilation at runtime.
# MLX's own install rules use the default component so they get skipped by
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
#
# Layout:
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ - CCCL headers
# ${OLLAMA_INSTALL_DIR}/include/*.h - CUDA toolkit headers
#
# MLX's jit_module.cpp resolves CCCL via
# current_binary_dir()[.parent_path()] / "include" / "cccl"
# On Linux, MLX's jit_module.cpp resolves CCCL via
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include.
# This will need refinement if we add multiple CUDA versions for MLX in the future.
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
COMPONENT MLX)
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/nv
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
COMPONENT MLX)
endif()
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
# $CUDA_PATH/include/*.h via NVRTC --include-path.
if(CUDAToolkit_FOUND)
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
# (e.g. ".../include;.../include/cccl"). Find the entry that
# contains the CUDA runtime headers we need.
set(_cuda_inc "")
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
if(EXISTS "${_dir}/cuda_runtime_api.h")
set(_cuda_inc "${_dir}")
break()
endif()
endforeach()
if(NOT _cuda_inc)
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
else()
set(_dst "${OLLAMA_INSTALL_DIR}/include")
set(_MLX_JIT_CUDA_HEADERS
builtin_types.h
cooperative_groups.h
cuda_bf16.h
cuda_bf16.hpp
cuda_device_runtime_api.h
cuda_fp16.h
cuda_fp16.hpp
cuda_fp8.h
cuda_fp8.hpp
cuda_runtime_api.h
device_types.h
driver_types.h
math_constants.h
surface_types.h
texture_types.h
vector_functions.h
vector_functions.hpp
vector_types.h
)
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
install(FILES "${_cuda_inc}/${_hdr}"
DESTINATION ${_dst}
COMPONENT MLX)
endforeach()
# Subdirectory headers.
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
DESTINATION ${_dst}
COMPONENT MLX
FILES_MATCHING PATTERN "*.h")
install(FILES "${_cuda_inc}/crt/host_defines.h"
DESTINATION "${_dst}/crt"
COMPONENT MLX)
if(NOT WIN32 AND NOT APPLE)
install(CODE "
set(_link \"${CMAKE_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}/include\")
set(_target \"${OLLAMA_RUNNER_DIR}/include\")
if(NOT EXISTS \${_link})
execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink \${_target} \${_link})
endif()
" COMPONENT MLX)
endif()
endif()
endif()
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation).
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
# dlfcn-win32 is a known CMake target with its own install rules (which install
# to the wrong destination). We must install it explicitly here.
if(WIN32)
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
DESTINATION ${OLLAMA_INSTALL_DIR}
COMPONENT MLX)
endif()
# Manually install CUDA runtime libraries that MLX loads via dlopen
# (not detected by RUNTIME_DEPENDENCIES since they aren't link-time deps).
if(CUDAToolkit_FOUND)
file(GLOB MLX_CUDA_LIBS
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcublasLt.so*"
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
if(MLX_CUDA_LIBS)
install(FILES ${MLX_CUDA_LIBS}
DESTINATION ${OLLAMA_INSTALL_DIR}
COMPONENT MLX_VENDOR)
endif()
endif()
+90
View File
@@ -0,0 +1,90 @@
{
"version": 3,
"configurePresets": [
{
"name": "default",
"binaryDir": "${sourceDir}/../../build/mlx",
"installDir": "${sourceDir}/../../dist",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded",
"OLLAMA_SOURCE_DIR": "${sourceDir}/../.."
}
},
{
"name": "mlx_cuda_v13_base",
"hidden": true,
"inherits": [ "default" ],
"cacheVariables": {
"CMAKE_CUDA_FLAGS": "-t 2",
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
}
},
{
"name": "mlx_cuda_v13_linux",
"inherits": [ "mlx_cuda_v13_base" ],
"binaryDir": "${sourceDir}/../../build/mlx_cuda_v13",
"cacheVariables": {
"MLX_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual"
}
},
{
"name": "mlx_cuda_v13_windows",
"inherits": [ "mlx_cuda_v13_base" ],
"binaryDir": "${sourceDir}/../../build/mlx_cuda_v13",
"cacheVariables": {
"MLX_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual"
}
},
{
"name": "mlx_cuda_v13_user_arch",
"inherits": [ "mlx_cuda_v13_base" ],
"binaryDir": "${sourceDir}/../../build/mlx_cuda_v13"
},
{
"name": "mlx_metal_v3",
"inherits": [ "default" ],
"binaryDir": "${sourceDir}/../../build/metal-v3",
"cacheVariables": {
"CMAKE_OSX_DEPLOYMENT_TARGET": "14.0",
"OLLAMA_RUNNER_DIR": "mlx_metal_v3"
}
},
{
"name": "mlx_metal_v4",
"inherits": [ "default" ],
"binaryDir": "${sourceDir}/../../build/metal-v4",
"cacheVariables": {
"CMAKE_OSX_DEPLOYMENT_TARGET": "26.0",
"OLLAMA_RUNNER_DIR": "mlx_metal_v4"
}
}
],
"buildPresets": [
{
"name": "mlx_cuda_v13_linux",
"configurePreset": "mlx_cuda_v13_linux",
"targets": [ "mlx", "mlxc" ]
},
{
"name": "mlx_cuda_v13_windows",
"configurePreset": "mlx_cuda_v13_windows",
"targets": [ "mlx", "mlxc" ]
},
{
"name": "mlx_cuda_v13_user_arch",
"configurePreset": "mlx_cuda_v13_user_arch",
"targets": [ "mlx", "mlxc" ]
},
{
"name": "mlx_metal_v3",
"configurePreset": "mlx_metal_v3",
"targets": [ "mlx", "mlxc" ]
},
{
"name": "mlx_metal_v4",
"configurePreset": "mlx_metal_v4",
"targets": [ "mlx", "mlxc" ]
}
]
}
+14
View File
@@ -0,0 +1,14 @@
if(NOT DEFINED MLX_C_HEADERS_DIR OR NOT IS_DIRECTORY "${MLX_C_HEADERS_DIR}")
message(FATAL_ERROR "MLX_C_HEADERS_DIR does not exist: ${MLX_C_HEADERS_DIR}")
endif()
if(NOT DEFINED MLX_C_HEADERS_DEST OR "${MLX_C_HEADERS_DEST}" STREQUAL "")
message(FATAL_ERROR "MLX_C_HEADERS_DEST is required")
endif()
file(GLOB _mlx_c_headers LIST_DIRECTORIES false "${MLX_C_HEADERS_DIR}/*.h")
if(NOT _mlx_c_headers)
message(FATAL_ERROR "No MLX-C headers found in ${MLX_C_HEADERS_DIR}")
endif()
file(MAKE_DIRECTORY "${MLX_C_HEADERS_DEST}")
file(COPY ${_mlx_c_headers} DESTINATION "${MLX_C_HEADERS_DEST}")
+69
View File
@@ -0,0 +1,69 @@
set(CMAKE_SYSTEM_NAME Windows)
set(CMAKE_SYSTEM_PROCESSOR ARM64)
set(_ollama_llvm_mingw_hints)
if(DEFINED ENV{ProgramFiles})
file(GLOB _ollama_program_files_llvm_mingw_bins
LIST_DIRECTORIES true
"$ENV{ProgramFiles}/llvm-mingw-*-x86_64*/bin")
list(SORT _ollama_program_files_llvm_mingw_bins COMPARE NATURAL ORDER DESCENDING)
list(APPEND _ollama_llvm_mingw_hints ${_ollama_program_files_llvm_mingw_bins})
endif()
if(DEFINED ENV{LOCALAPPDATA})
file(GLOB _ollama_winget_llvm_mingw_bins
LIST_DIRECTORIES true
"$ENV{LOCALAPPDATA}/Microsoft/WinGet/Packages/MartinStorsjo.LLVM-MinGW*/llvm-mingw-*-x86_64*/bin")
list(SORT _ollama_winget_llvm_mingw_bins COMPARE NATURAL ORDER DESCENDING)
list(APPEND _ollama_llvm_mingw_hints ${_ollama_winget_llvm_mingw_bins})
endif()
if(NOT CMAKE_C_COMPILER)
find_program(CMAKE_C_COMPILER
NAMES aarch64-w64-mingw32-gcc
HINTS ${_ollama_llvm_mingw_hints}
REQUIRED)
endif()
if(NOT CMAKE_CXX_COMPILER)
find_program(CMAKE_CXX_COMPILER
NAMES aarch64-w64-mingw32-g++
HINTS ${_ollama_llvm_mingw_hints}
REQUIRED)
endif()
get_filename_component(_ollama_llvm_mingw_bin_dir "${CMAKE_CXX_COMPILER}" DIRECTORY)
if(NOT HOST_CXX_COMPILER)
find_program(_ollama_path_host_cxx
NAMES clang++ g++
NO_CMAKE_FIND_ROOT_PATH)
if(_ollama_path_host_cxx)
set(HOST_CXX_COMPILER "${_ollama_path_host_cxx}")
endif()
endif()
if(NOT HOST_CXX_COMPILER)
find_program(_ollama_mingw_host_cxx
NAMES x86_64-w64-mingw32-g++
HINTS "${_ollama_llvm_mingw_bin_dir}"
REQUIRED)
if(CMAKE_HOST_WIN32)
# llama.cpp builds a small host-only UI embedding tool during
# cross-compiles, but currently models HOST_CXX_COMPILER as only an
# executable path and has no companion host flags hook. When the host
# compiler is llvm-mingw, the generated host tool otherwise depends on
# llvm-mingw runtime DLLs being on PATH. Keep that workaround local and
# explicit: wrap the compiler only to add -static for this host tool.
set(_ollama_host_cxx_wrapper "${CMAKE_BINARY_DIR}/ollama-host-cxx.cmd")
file(TO_NATIVE_PATH "${_ollama_mingw_host_cxx}" _ollama_mingw_host_cxx_native)
file(WRITE "${_ollama_host_cxx_wrapper}"
"@echo off\r\n"
"\"${_ollama_mingw_host_cxx_native}\" -static %*\r\n")
set(HOST_CXX_COMPILER "${_ollama_host_cxx_wrapper}")
else()
set(HOST_CXX_COMPILER "${_ollama_mingw_host_cxx}")
endif()
endif()
set(HOST_CXX_COMPILER "${HOST_CXX_COMPILER}" CACHE FILEPATH "Host C++ compiler for build-time tools" FORCE)
string(PREPEND CMAKE_C_FLAGS_INIT "-D_WIN32_WINNT=0x0A00 ")
string(PREPEND CMAKE_CXX_FLAGS_INIT "-D_WIN32_WINNT=0x0A00 ")
+124 -19
View File
@@ -18,6 +18,7 @@ import (
"os"
"os/exec"
"os/signal"
"path"
"path/filepath"
"runtime"
"slices"
@@ -41,6 +42,7 @@ import (
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/launch"
"github.com/ollama/ollama/cmd/tui"
"github.com/ollama/ollama/discover"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/format"
"github.com/ollama/ollama/internal/modelref"
@@ -232,9 +234,6 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
// This gates both safetensors LLM and imagegen model creation
experimental, _ := cmd.Flags().GetBool("experimental")
draftQuantize, _ := cmd.Flags().GetString("draft-quantize")
if draftQuantize != "" && !experimental {
return errors.New("--draft-quantize requires --experimental")
}
if experimental {
if !isLocalhost() {
return errors.New("remote safetensor model creation not yet supported")
@@ -329,6 +328,12 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
if quantize != "" {
req.Quantize = quantize
}
if draftQuantize != "" {
if len(req.DraftFiles) == 0 {
return errors.New("--draft-quantize requires a DRAFT model")
}
req.DraftQuantize = draftQuantize
}
client, err := api.ClientFromEnvironment()
if err != nil {
@@ -339,29 +344,40 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
g.SetLimit(max(runtime.GOMAXPROCS(0)-1, 1))
files := syncmap.NewSyncMap[string, string]()
fileNames := createRequestFileNames(req.Files)
for f, digest := range req.Files {
g.Go(func() error {
if _, err := createBlob(cmd, client, f, digest, p); err != nil {
return err
}
// TODO: this is incorrect since the file might be in a subdirectory
// instead this should take the path relative to the model directory
// but the current implementation does not allow this
files.Store(filepath.Base(f), digest)
files.Store(fileNames[f], digest)
return nil
})
}
adapters := syncmap.NewSyncMap[string, string]()
adapterNames := createRequestFileNames(req.Adapters)
for f, digest := range req.Adapters {
g.Go(func() error {
if _, err := createBlob(cmd, client, f, digest, p); err != nil {
return err
}
// TODO: same here
adapters.Store(filepath.Base(f), digest)
adapters.Store(adapterNames[f], digest)
return nil
})
}
draftFiles := syncmap.NewSyncMap[string, string]()
draftFileNames := createRequestFileNames(req.DraftFiles)
for f, digest := range req.DraftFiles {
g.Go(func() error {
if _, err := createBlob(cmd, client, f, digest, p); err != nil {
return err
}
draftFiles.Store(draftFileNames[f], digest)
return nil
})
}
@@ -372,6 +388,7 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
req.Files = files.Items()
req.Adapters = adapters.Items()
req.DraftFiles = draftFiles.Items()
bars := make(map[string]*progress.Bar)
fn := func(resp api.ProgressResponse) error {
@@ -409,6 +426,65 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
return nil
}
func createRequestFileNames(files map[string]string) map[string]string {
names := make(map[string]string, len(files))
root, ok := commonFileRoot(files)
for f := range files {
name := filepath.Base(f)
if ok {
abs, err := filepath.Abs(f)
if err == nil {
if rel, err := filepath.Rel(root, abs); err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
name = rel
}
}
}
names[f] = path.Clean(filepath.ToSlash(name))
}
return names
}
func commonFileRoot(files map[string]string) (string, bool) {
if len(files) < 2 {
return "", false
}
var root string
var volume string
for f := range files {
abs, err := filepath.Abs(f)
if err != nil {
return "", false
}
if nextVolume := filepath.VolumeName(abs); volume == "" {
volume = nextVolume
} else if !strings.EqualFold(volume, nextVolume) {
return "", false
}
dir := filepath.Dir(abs)
if root == "" {
root = dir
continue
}
for {
rel, err := filepath.Rel(root, dir)
if err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))) {
break
}
parent := filepath.Dir(root)
if parent == root {
return "", false
}
root = parent
}
}
return root, root != ""
}
func createBlob(cmd *cobra.Command, client *api.Client, path string, digest string, p *progress.Progress) (string, error) {
realPath, err := filepath.EvalSymlinks(path)
if err != nil {
@@ -1277,11 +1353,28 @@ func showInfo(resp *api.ShowResponse, verbose bool, w io.Writer) error {
if resp.ProjectorInfo != nil {
tableRender("Projector", func() (rows [][]string) {
arch := resp.ProjectorInfo["general.architecture"].(string)
rows = append(rows, []string{"", "architecture", arch})
rows = append(rows, []string{"", "parameters", format.HumanNumber(uint64(resp.ProjectorInfo["general.parameter_count"].(float64)))})
rows = append(rows, []string{"", "embedding length", strconv.FormatFloat(resp.ProjectorInfo[fmt.Sprintf("%s.vision.embedding_length", arch)].(float64), 'f', -1, 64)})
rows = append(rows, []string{"", "dimensions", strconv.FormatFloat(resp.ProjectorInfo[fmt.Sprintf("%s.vision.projection_dim", arch)].(float64), 'f', -1, 64)})
arch, _ := resp.ProjectorInfo["general.architecture"].(string)
if arch != "" {
rows = append(rows, []string{"", "architecture", arch})
}
if v, ok := resp.ProjectorInfo["general.parameter_count"].(float64); ok {
rows = append(rows, []string{"", "parameters", format.HumanNumber(uint64(v))})
}
projectorValue := func(suffix string) (float64, bool) {
for _, modality := range []string{"vision", "audio"} {
if v, ok := resp.ProjectorInfo[fmt.Sprintf("%s.%s.%s", arch, modality, suffix)].(float64); ok {
return v, true
}
}
return 0, false
}
if v, ok := projectorValue("embedding_length"); ok {
rows = append(rows, []string{"", "embedding length", strconv.FormatFloat(v, 'f', -1, 64)})
}
if v, ok := projectorValue("projection_dim"); ok {
rows = append(rows, []string{"", "dimensions", strconv.FormatFloat(v, 'f', -1, 64)})
}
return
})
}
@@ -2009,7 +2102,7 @@ func appendEnvDocs(cmd *cobra.Command, envs []envconfig.EnvVar) {
Environment Variables:
`
for _, e := range envs {
envUsage += fmt.Sprintf(" %-24s %s\n", e.Name, e.Description)
envUsage += fmt.Sprintf(" %-27s %s\n", e.Name, e.Description)
}
cmd.SetUsageTemplate(cmd.UsageTemplate() + envUsage)
@@ -2231,7 +2324,7 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
func launcherActionExitsLoop(integration string) bool {
switch integration {
case "vscode":
case "codex-app", "vscode":
return true
default:
return false
@@ -2277,9 +2370,6 @@ func NewCLI() *cobra.Command {
if experimental, _ := cmd.Flags().GetBool("experimental"); experimental {
return nil
}
if draftQuantize, _ := cmd.Flags().GetString("draft-quantize"); draftQuantize != "" {
return errors.New("--draft-quantize requires --experimental")
}
return checkServerHeartbeat(cmd, args)
},
RunE: CreateHandler,
@@ -2445,6 +2535,16 @@ func NewCLI() *cobra.Command {
_ = runner.Execute(args[1:])
})
var gpuDiscoverLibDirs []string
gpuDiscoverCmd := &cobra.Command{
Use: "gpu-discover",
Hidden: true,
RunE: func(cmd *cobra.Command, _ []string) error {
return discover.RunNativeProbeCommand(cmd.Context(), gpuDiscoverLibDirs, os.Stdout)
},
}
gpuDiscoverCmd.Flags().StringArrayVar(&gpuDiscoverLibDirs, "lib-dir", nil, "Ollama runtime library directory")
envVars := envconfig.AsMap()
envs := []envconfig.EnvVar{envVars["OLLAMA_HOST"]}
@@ -2473,6 +2573,7 @@ func NewCLI() *cobra.Command {
envVars["OLLAMA_CONTEXT_LENGTH"],
envVars["OLLAMA_KEEP_ALIVE"],
envVars["OLLAMA_MAX_LOADED_MODELS"],
envVars["OLLAMA_MAX_TRANSFER_STREAMS"],
envVars["OLLAMA_MAX_QUEUE"],
envVars["OLLAMA_MODELS"],
envVars["OLLAMA_NUM_PARALLEL"],
@@ -2484,6 +2585,9 @@ func NewCLI() *cobra.Command {
envVars["OLLAMA_KV_CACHE_TYPE"],
envVars["OLLAMA_LLM_LIBRARY"],
envVars["OLLAMA_GPU_OVERHEAD"],
envVars["OLLAMA_IGPU_ENABLE"],
envVars["LLAMA_ARG_FIT"],
envVars["LLAMA_ARG_FIT_TARGET"],
envVars["OLLAMA_LOAD_TIMEOUT"],
})
default:
@@ -2508,6 +2612,7 @@ func NewCLI() *cobra.Command {
copyCmd,
deleteCmd,
runnerCmd,
gpuDiscoverCmd,
launch.LaunchCmd(checkServerHeartbeat, runInteractiveTUI),
)
+1 -1
View File
@@ -249,7 +249,7 @@ func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) {
cmd := &cobra.Command{}
cmd.SetContext(context.Background())
for _, integration := range []string{"vscode"} {
for _, integration := range []string{"codex-app", "vscode"} {
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{
resolveRunModel: unexpectedRunModelResolution(t),
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
+44 -13
View File
@@ -1525,34 +1525,65 @@ func TestCreateHandler(t *testing.T) {
}
}
func TestCreateHandlerDraftQuantizeRequiresExperimental(t *testing.T) {
cmd := &cobra.Command{}
cmd.Flags().Bool("experimental", false, "")
cmd.Flags().String("draft-quantize", "mxfp8", "")
cmd.SetContext(t.Context())
func TestCreateRequestFileNamesPreservesModelDirectoryLayout(t *testing.T) {
root := t.TempDir()
files := map[string]string{
filepath.Join(root, "model.safetensors"): "sha256:model",
filepath.Join(root, "config.json"): "sha256:config",
filepath.Join(root, "2_Dense", "config.json"): "sha256:dense-config",
filepath.Join(root, "2_Dense", "model.safetensors"): "sha256:dense-model",
}
err := CreateHandler(cmd, []string{"test-model"})
if err == nil || !strings.Contains(err.Error(), "--draft-quantize requires --experimental") {
t.Fatalf("error = %v, want draft-quantize requires experimental", err)
got := createRequestFileNames(files)
want := map[string]string{
filepath.Join(root, "model.safetensors"): "model.safetensors",
filepath.Join(root, "config.json"): "config.json",
filepath.Join(root, "2_Dense", "config.json"): "2_Dense/config.json",
filepath.Join(root, "2_Dense", "model.safetensors"): "2_Dense/model.safetensors",
}
if diff := cmp.Diff(want, got); diff != "" {
t.Fatalf("mismatch (-want +got):\n%s", diff)
}
}
func TestCreateHandlerDraftRequiresExperimental(t *testing.T) {
func TestCreateRequestFileNamesPreservesRelativeModelDirectoryLayout(t *testing.T) {
root := t.TempDir()
t.Chdir(root)
files := map[string]string{
"model.safetensors": "sha256:model",
"config.json": "sha256:config",
"2_Dense/config.json": "sha256:dense-config",
"2_Dense/model.safetensors": "sha256:dense-model",
"3_Dense/config.json": "sha256:dense-config",
"3_Dense/model.safetensors": "sha256:dense-model",
}
got := createRequestFileNames(files)
for file := range files {
if got[file] != filepath.ToSlash(file) {
t.Fatalf("%s = %q, want %q", file, got[file], filepath.ToSlash(file))
}
}
}
func TestCreateHandlerDraftQuantizeRequiresDraft(t *testing.T) {
dir := t.TempDir()
modelfile := filepath.Join(dir, "Modelfile")
if err := os.WriteFile(modelfile, []byte("FROM base\nDRAFT ./assistant\n"), 0o644); err != nil {
if err := os.WriteFile(modelfile, []byte("FROM base\n"), 0o644); err != nil {
t.Fatal(err)
}
cmd := &cobra.Command{}
cmd.Flags().Bool("experimental", false, "")
cmd.Flags().String("draft-quantize", "", "")
cmd.Flags().String("file", modelfile, "")
cmd.Flags().String("draft-quantize", "mxfp8", "")
cmd.SetContext(t.Context())
err := CreateHandler(cmd, []string{"test-model"})
if err == nil || !strings.Contains(err.Error(), "DRAFT requires --experimental") {
t.Fatalf("error = %v, want DRAFT requires --experimental", err)
if err == nil || !strings.Contains(err.Error(), "--draft-quantize requires a DRAFT model") {
t.Fatalf("error = %v, want draft-quantize requires DRAFT", err)
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ func (c *Claude) findPath() (string, error) {
return fallback, nil
}
func (c *Claude) Run(model string, args []string) error {
func (c *Claude) Run(model string, _ []LaunchModel, args []string) error {
claudePath, err := c.findPath()
if err != nil {
return fmt.Errorf("claude is not installed, install from https://code.claude.com/docs/en/quickstart")
+1 -1
View File
@@ -130,7 +130,7 @@ func (c *ClaudeDesktop) SkipModelReadiness() bool {
return true
}
func (c *ClaudeDesktop) Run(_ string, _ []string) error {
func (c *ClaudeDesktop) Run(_ string, _ []LaunchModel, _ []string) error {
return errClaudeDesktopUnsupported()
}
+1 -1
View File
@@ -932,7 +932,7 @@ func TestClaudeDesktopRunReturnsUnsupported(t *testing.T) {
)
for _, args := range [][]string{nil, {"--foo"}} {
err := (&ClaudeDesktop{}).Run("qwen3.5", args)
err := (&ClaudeDesktop{}).Run("qwen3.5", nil, args)
if err == nil {
t.Fatal("expected Run to fail")
}
+4 -4
View File
@@ -16,7 +16,7 @@ type Cline struct{}
func (c *Cline) String() string { return "Cline" }
func (c *Cline) Run(model string, args []string) error {
func (c *Cline) Run(model string, _ []LaunchModel, args []string) error {
if _, err := exec.LookPath("cline"); err != nil {
return fmt.Errorf("cline is not installed, install with: npm install -g cline")
}
@@ -40,7 +40,7 @@ func (c *Cline) Paths() []string {
return nil
}
func (c *Cline) Edit(models []string) error {
func (c *Cline) Edit(models []LaunchModel) error {
if len(models) == 0 {
return nil
}
@@ -66,10 +66,10 @@ func (c *Cline) Edit(models []string) error {
baseURL := envconfig.Host().String()
config["ollamaBaseUrl"] = baseURL
config["actModeApiProvider"] = "ollama"
config["actModeOllamaModelId"] = models[0]
config["actModeOllamaModelId"] = models[0].Name
config["actModeOllamaBaseUrl"] = baseURL
config["planModeApiProvider"] = "ollama"
config["planModeOllamaModelId"] = models[0]
config["planModeOllamaModelId"] = models[0].Name
config["planModeOllamaBaseUrl"] = baseURL
config["welcomeViewCompleted"] = true
+5 -5
View File
@@ -43,7 +43,7 @@ func TestClineEdit(t *testing.T) {
t.Run("creates config from scratch", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil {
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
t.Fatal(err)
}
@@ -77,7 +77,7 @@ func TestClineEdit(t *testing.T) {
data, _ := json.Marshal(existing)
os.WriteFile(configPath, data, 0o644)
if err := c.Edit([]string{"glm-5:cloud"}); err != nil {
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
t.Fatal(err)
}
@@ -93,10 +93,10 @@ func TestClineEdit(t *testing.T) {
t.Run("updates model on re-edit", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil {
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
t.Fatal(err)
}
if err := c.Edit([]string{"glm-5:cloud"}); err != nil {
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
t.Fatal(err)
}
@@ -124,7 +124,7 @@ func TestClineEdit(t *testing.T) {
t.Run("uses first model as primary", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
if err := c.Edit([]string{"kimi-k2.5:cloud", "glm-5:cloud"}); err != nil {
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud", "glm-5:cloud")); err != nil {
t.Fatal(err)
}
+571 -48
View File
@@ -1,13 +1,17 @@
package launch
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/types/model"
"github.com/pelletier/go-toml/v2"
"golang.org/x/mod/semver"
)
@@ -16,10 +20,22 @@ type Codex struct{}
func (c *Codex) String() string { return "Codex" }
const codexProfileName = "ollama-launch"
const (
codexProfileName = "ollama-launch"
codexProviderName = "Ollama"
codexFallbackContextWindow = 128_000
func (c *Codex) args(model string, extra []string) []string {
codexRootProfileKey = "profile"
codexRootModelKey = "model"
codexRootModelProviderKey = "model_provider"
codexRootModelCatalogJSONKey = "model_catalog_json"
)
func (c *Codex) args(model, modelCatalogPath string, extra []string) []string {
args := []string{"--profile", codexProfileName}
if modelCatalogPath != "" {
args = append(args, "-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
}
if model != "" {
args = append(args, "-m", model)
}
@@ -27,16 +43,21 @@ func (c *Codex) args(model string, extra []string) []string {
return args
}
func (c *Codex) Run(model string, args []string) error {
func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
if err := checkCodexVersion(); err != nil {
return err
}
if err := ensureCodexConfig(); err != nil {
if err := ensureCodexConfig(model, models); err != nil {
return fmt.Errorf("failed to configure codex: %w", err)
}
cmd := exec.Command("codex", c.args(model, args)...)
catalogPath, err := codexModelCatalogPath()
if err != nil {
return fmt.Errorf("failed to configure codex: %w", err)
}
cmd := exec.Command("codex", c.args(model, catalogPath, args)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -46,79 +67,581 @@ func (c *Codex) Run(model string, args []string) error {
return cmd.Run()
}
// ensureCodexConfig writes a [profiles.ollama-launch] section to ~/.codex/config.toml
// with openai_base_url pointing to the local Ollama server.
func ensureCodexConfig() error {
home, err := os.UserHomeDir()
// ensureCodexConfig writes a Codex profile and model catalog so Codex uses the
// local Ollama server and has model metadata available.
func ensureCodexConfig(modelName string, models []LaunchModel) error {
configPath, err := codexConfigPath()
if err != nil {
return err
}
codexDir := filepath.Join(home, ".codex")
codexDir := filepath.Dir(configPath)
if err := os.MkdirAll(codexDir, 0o755); err != nil {
return err
}
configPath := filepath.Join(codexDir, "config.toml")
return writeCodexProfile(configPath)
catalogPath := codexModelCatalogPathForConfig(configPath)
if err := writeCodexModelCatalog(catalogPath, codexCatalogModel(modelName, models)); err != nil {
return err
}
return writeCodexProfile(configPath, catalogPath)
}
func codexConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".codex", "config.toml"), nil
}
func codexModelCatalogPath() (string, error) {
configPath, err := codexConfigPath()
if err != nil {
return "", err
}
return codexModelCatalogPathForConfig(configPath), nil
}
func codexModelCatalogPathForConfig(configPath string) string {
return filepath.Join(filepath.Dir(configPath), "model.json")
}
// writeCodexProfile ensures ~/.codex/config.toml has the ollama-launch profile
// and model provider sections with the correct base URL.
func writeCodexProfile(configPath string) error {
baseURL := envconfig.Host().String() + "/v1/"
func writeCodexProfile(configPath string, modelCatalogPath ...string) error {
opts := codexLaunchProfileOptions{
forceAPIAuth: true,
}
if len(modelCatalogPath) > 0 {
opts.modelCatalogPath = modelCatalogPath[0]
}
return writeCodexLaunchProfile(configPath, opts)
}
type codexLaunchProfileOptions struct {
activate bool
profileName string
forceAPIAuth bool
setRootModelConfig bool
model string
modelCatalogPath string
backupIntegration string
}
func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) error {
baseURL := codexBaseURL()
profileName := codexLaunchProfileName(opts)
profileHeader := codexProfileHeaderFor(profileName)
providerHeader := codexProviderHeaderFor(profileName)
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
} else if !os.IsNotExist(readErr) {
return readErr
}
parsed, err := codexParseConfig(text)
if err != nil {
return err
}
model := strings.TrimSpace(opts.model)
if model == "" {
model = parsed.ProfileString(profileName, codexRootModelKey)
}
modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath)
if modelCatalogPath == "" {
modelCatalogPath = parsed.ProfileString(profileName, codexRootModelCatalogJSONKey)
}
profileLines := []string{}
if model != "" {
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelKey, model))
}
profileLines = append(profileLines,
fmt.Sprintf("openai_base_url = %q", baseURL),
fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName),
)
if opts.forceAPIAuth {
profileLines = append(profileLines, `forced_login_method = "api"`)
}
if modelCatalogPath != "" {
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath))
}
sections := []struct {
header string
lines []string
}{
{
header: fmt.Sprintf("[profiles.%s]", codexProfileName),
lines: []string{
fmt.Sprintf("openai_base_url = %q", baseURL),
`forced_login_method = "api"`,
fmt.Sprintf("model_provider = %q", codexProfileName),
},
header: profileHeader,
lines: profileLines,
},
{
header: fmt.Sprintf("[model_providers.%s]", codexProfileName),
header: providerHeader,
lines: []string{
`name = "Ollama"`,
fmt.Sprintf("name = %q", codexProviderName),
fmt.Sprintf("base_url = %q", baseURL),
`wire_api = "responses"`,
},
},
}
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
if opts.activate {
text = codexSetRootStringValue(text, codexRootProfileKey, profileName)
}
for _, s := range sections {
block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n"
if idx := strings.Index(text, s.header); idx >= 0 {
// Replace the existing section up to the next section header.
rest := text[idx+len(s.header):]
if endIdx := strings.Index(rest, "\n["); endIdx >= 0 {
text = text[:idx] + block + rest[endIdx+1:]
} else {
text = text[:idx] + block
}
} else {
// Append the section.
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
if text != "" {
text += "\n"
}
text += block
if opts.setRootModelConfig {
if model != "" {
text = codexSetRootStringValue(text, codexRootModelKey, model)
}
text = codexSetRootStringValue(text, codexRootModelProviderKey, profileName)
if modelCatalogPath != "" {
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
}
}
return os.WriteFile(configPath, []byte(text), 0o644)
for _, s := range sections {
text = codexUpsertSection(text, s.header, s.lines)
}
parsed, err = codexParseConfig(text)
if err != nil {
return err
}
if err := codexValidateLaunchProfileText(parsed, profileName, opts, model, modelCatalogPath, baseURL); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, []byte(text), opts.backupIntegration)
}
func codexLaunchProfileName(opts codexLaunchProfileOptions) string {
if name := strings.TrimSpace(opts.profileName); name != "" {
return name
}
return codexProfileName
}
func codexBaseURL() string {
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1/"
}
func codexProfileHeader() string {
return codexProfileHeaderFor(codexProfileName)
}
func codexProviderHeader() string {
return codexProviderHeaderFor(codexProfileName)
}
func codexProfileHeaderFor(profileName string) string {
return fmt.Sprintf("[profiles.%s]", profileName)
}
func codexProviderHeaderFor(profileName string) string {
return fmt.Sprintf("[model_providers.%s]", profileName)
}
func codexValidateLaunchProfileText(config codexParsedConfig, profileName string, opts codexLaunchProfileOptions, model, modelCatalogPath, baseURL string) error {
for _, check := range []struct {
path []string
want string
}{
{[]string{"profiles", profileName, "openai_base_url"}, baseURL},
{[]string{"profiles", profileName, codexRootModelProviderKey}, profileName},
{[]string{"model_providers", profileName, "name"}, codexProviderName},
{[]string{"model_providers", profileName, "base_url"}, baseURL},
{[]string{"model_providers", profileName, "wire_api"}, "responses"},
} {
if got, ok := config.String(check.path...); !ok || got != check.want {
return fmt.Errorf("generated Codex config missing %s = %q", strings.Join(check.path, "."), check.want)
}
}
if opts.forceAPIAuth {
if got, ok := config.String("profiles", profileName, "forced_login_method"); !ok || got != "api" {
return fmt.Errorf("generated Codex config missing profiles.%s.forced_login_method = %q", profileName, "api")
}
}
if model != "" {
if got, ok := config.String("profiles", profileName, codexRootModelKey); !ok || got != model {
return fmt.Errorf("generated Codex config missing profiles.%s.model = %q", profileName, model)
}
}
if modelCatalogPath != "" {
if got, ok := config.String("profiles", profileName, codexRootModelCatalogJSONKey); !ok || got != modelCatalogPath {
return fmt.Errorf("generated Codex config missing profiles.%s.model_catalog_json = %q", profileName, modelCatalogPath)
}
}
if opts.activate {
if got := config.RootString(codexRootProfileKey); got != profileName {
return fmt.Errorf("generated Codex config missing profile = %q", profileName)
}
}
if opts.setRootModelConfig {
if model != "" {
if got := config.RootString(codexRootModelKey); got != model {
return fmt.Errorf("generated Codex config missing model = %q", model)
}
}
if got := config.RootString(codexRootModelProviderKey); got != profileName {
return fmt.Errorf("generated Codex config missing model_provider = %q", profileName)
}
if modelCatalogPath != "" {
if got := config.RootString(codexRootModelCatalogJSONKey); got != modelCatalogPath {
return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath)
}
}
}
return nil
}
func codexUpsertSection(text, header string, lines []string) string {
block := strings.Join(append([]string{header}, lines...), "\n") + "\n"
if targetPath, ok := codexTableHeaderPath(header); ok {
if start, end, found := codexSectionRange(text, targetPath); found {
return text[:start] + block + text[end:]
}
}
if text != "" && !strings.HasSuffix(text, "\n") {
text += "\n"
}
if text != "" {
text += "\n"
}
return text + block
}
func codexRemoveSection(text, header string) string {
targetPath, ok := codexTableHeaderPath(header)
if !ok {
return text
}
start, end, found := codexSectionRange(text, targetPath)
if !found {
return text
}
return text[:start] + text[end:]
}
type codexParsedConfig struct {
values map[string]any
}
func (c codexParsedConfig) String(path ...string) (string, bool) {
if len(path) == 0 {
return "", false
}
var current any = c.values
for _, part := range path {
table, ok := current.(map[string]any)
if !ok {
return "", false
}
current, ok = table[part]
if !ok {
return "", false
}
}
value, ok := current.(string)
if !ok {
return "", false
}
return value, true
}
func (c codexParsedConfig) RootString(key string) string {
value, _ := c.RootStringOK(key)
return value
}
func (c codexParsedConfig) RootStringOK(key string) (string, bool) {
return c.String(key)
}
func (c codexParsedConfig) ProfileString(profileName, key string) string {
value, _ := c.String("profiles", profileName, key)
return value
}
func (c codexParsedConfig) ProviderString(profileName, key string) string {
value, _ := c.String("model_providers", profileName, key)
return value
}
func codexRootStringValue(text, key string) string {
config, err := codexParseConfig(text)
if err != nil {
return ""
}
return config.RootString(key)
}
func codexRootStringValueOK(text, key string) (string, bool) {
config, err := codexParseConfig(text)
if err != nil {
return "", false
}
return config.RootStringOK(key)
}
func codexStringValue(text string, path ...string) (string, bool) {
config, err := codexParseConfig(text)
if err != nil {
return "", false
}
return config.String(path...)
}
func codexSectionStringValue(text, header, key string) string {
path, ok := codexTableHeaderPath(header)
if !ok {
return ""
}
value, _ := codexStringValue(text, append(path, key)...)
return value
}
func codexParseConfig(text string) (codexParsedConfig, error) {
values, err := codexParseConfigText(text)
if err != nil {
return codexParsedConfig{}, err
}
return codexParsedConfig{values: values}, nil
}
func codexParseConfigText(text string) (map[string]any, error) {
cfg := map[string]any{}
if strings.TrimSpace(text) == "" {
return cfg, nil
}
if err := toml.Unmarshal([]byte(text), &cfg); err != nil {
return nil, fmt.Errorf("invalid Codex config TOML: %w", err)
}
return cfg, nil
}
func codexValidateConfigText(text string) error {
_, err := codexParseConfig(text)
return err
}
func codexSectionRange(text string, targetPath []string) (int, int, bool) {
lines := strings.SplitAfter(text, "\n")
offset := 0
start := -1
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "#") {
offset += len(line)
continue
}
if start >= 0 {
return start, offset, true
}
if path, ok := codexTableHeaderPath(trimmed); ok && codexSamePath(path, targetPath) {
start = offset
}
offset += len(line)
}
if start >= 0 {
return start, len(text), true
}
return 0, 0, false
}
func codexTableHeaderPath(header string) ([]string, bool) {
trimmed := strings.TrimSpace(header)
if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "[[") {
return nil, false
}
const probeKey = "__ollama_launch_probe"
cfg := map[string]any{}
if err := toml.Unmarshal([]byte(trimmed+"\n"+probeKey+" = true\n"), &cfg); err != nil {
return nil, false
}
return codexFindProbePath(cfg, probeKey, nil)
}
func codexFindProbePath(value any, probeKey string, path []string) ([]string, bool) {
table, ok := value.(map[string]any)
if !ok {
return nil, false
}
if probe, ok := table[probeKey].(bool); ok && probe {
return path, true
}
for key, child := range table {
if key == probeKey {
continue
}
if childPath, ok := codexFindProbePath(child, probeKey, append(path, key)); ok {
return childPath, true
}
}
return nil, false
}
func codexSamePath(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func codexSetRootStringValue(text, key, value string) string {
lines := strings.SplitAfter(text, "\n")
rootEnd := len(lines)
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "[") {
rootEnd = i
break
}
}
assignment := fmt.Sprintf("%s = %q", key, value)
for i := range rootEnd {
line := lines[i]
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
if codexRootLineHasKey(trimmed, key) {
if strings.HasSuffix(line, "\n") {
lines[i] = assignment + "\n"
} else {
lines[i] = assignment
}
return strings.Join(lines, "")
}
}
insert := assignment + "\n"
root := strings.Join(lines[:rootEnd], "")
rest := strings.Join(lines[rootEnd:], "")
if root != "" && !strings.HasSuffix(root, "\n") {
root += "\n"
}
if rest != "" && !strings.HasSuffix(insert, "\n\n") {
insert += "\n"
}
return root + insert + rest
}
func codexRemoveRootValue(text, key string) string {
lines := strings.SplitAfter(text, "\n")
rootEnd := len(lines)
for i, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "[") {
rootEnd = i
break
}
}
out := make([]string, 0, len(lines))
for i, line := range lines {
if i < rootEnd {
trimmed := strings.TrimSpace(line)
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && codexRootLineHasKey(trimmed, key) {
continue
}
}
out = append(out, line)
}
return strings.Join(out, "")
}
func codexRootLineHasKey(line, key string) bool {
cfg := map[string]any{}
if err := toml.Unmarshal([]byte(line+"\n"), &cfg); err != nil {
return false
}
_, ok := cfg[key]
return ok
}
func codexCatalogModel(modelName string, models []LaunchModel) LaunchModel {
if model, ok := findLaunchModel(models, modelName); ok {
return model.WithCloudLimits()
}
return fallbackLaunchModel(modelName)
}
func writeCodexModelCatalog(catalogPath string, model LaunchModel) error {
entry := buildCodexModelEntry(model)
catalog := map[string]any{
"models": []any{entry},
}
data, err := json.MarshalIndent(catalog, "", " ")
if err != nil {
return err
}
return os.WriteFile(catalogPath, data, 0o644)
}
func buildCodexModelEntry(launchModel LaunchModel) map[string]any {
modelName := launchModel.Name
contextWindow := codexFallbackContextWindow
systemPrompt := ""
if launchModel.ContextLength > 0 {
contextWindow = launchModel.ContextLength
} else if launchModel.Details.ContextLength > 0 {
contextWindow = launchModel.Details.ContextLength
}
if l, ok := lookupCloudModelLimit(modelName); ok {
contextWindow = l.Context
}
if !isCloudModelName(modelName) && launchModel.Details.Format != "safetensors" {
if ctxLen := envconfig.ContextLength(); ctxLen > 0 {
contextWindow = int(ctxLen)
}
}
modalities := []string{"text"}
if launchModel.HasCapability(model.CapabilityVision) {
modalities = append(modalities, "image")
}
truncationMode := "bytes"
if isCloudModelName(modelName) {
truncationMode = "tokens"
}
return map[string]any{
"slug": modelName,
"display_name": modelName,
"context_window": contextWindow,
"shell_type": "default",
"visibility": "list",
"supported_in_api": true,
"priority": 0,
"truncation_policy": map[string]any{"mode": truncationMode, "limit": 10000},
"input_modalities": modalities,
"base_instructions": systemPrompt,
"support_verbosity": true,
"default_verbosity": "low",
"supports_parallel_tool_calls": false,
"supports_reasoning_summaries": false,
"supported_reasoning_levels": []any{},
"experimental_supported_tools": []any{},
}
}
func checkCodexVersion() error {
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+380 -14
View File
@@ -1,15 +1,23 @@
package launch
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
modelpkg "github.com/ollama/ollama/types/model"
)
func TestCodexArgs(t *testing.T) {
c := &Codex{}
catalogPath := filepath.Join("tmp", "model.json")
catalogArg := fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath)
tests := []struct {
name string
@@ -17,15 +25,15 @@ func TestCodexArgs(t *testing.T) {
args []string
want []string
}{
{"with model", "llama3.2", nil, []string{"--profile", "ollama-launch", "-m", "llama3.2"}},
{"empty model", "", nil, []string{"--profile", "ollama-launch"}},
{"with model and extra args", "qwen3.5", []string{"-p", "myprofile"}, []string{"--profile", "ollama-launch", "-m", "qwen3.5", "-p", "myprofile"}},
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--profile", "ollama-launch", "-m", "llama3.2", "--sandbox", "workspace-write"}},
{"with model", "llama3.2", nil, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "llama3.2"}},
{"empty model", "", nil, []string{"--profile", "ollama-launch", "-c", catalogArg}},
{"with model and extra args", "qwen3.5", []string{"-p", "myprofile"}, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "qwen3.5", "-p", "myprofile"}},
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "llama3.2", "--sandbox", "workspace-write"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.args(tt.model, tt.args)
got := c.args(tt.model, catalogPath, tt.args)
if !slices.Equal(got, tt.want) {
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
}
@@ -37,8 +45,9 @@ func TestWriteCodexProfile(t *testing.T) {
t.Run("creates new file when none exists", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
if err := writeCodexProfile(configPath); err != nil {
if err := writeCodexProfile(configPath, catalogPath); err != nil {
t.Fatal(err)
}
@@ -63,21 +72,28 @@ func TestWriteCodexProfile(t *testing.T) {
if !strings.Contains(content, `model_provider = "ollama-launch"`) {
t.Error("missing model_provider key")
}
if !strings.Contains(content, fmt.Sprintf("model_catalog_json = %q", catalogPath)) {
t.Error("missing model_catalog_json key")
}
if !strings.Contains(content, "[model_providers.ollama-launch]") {
t.Error("missing [model_providers.ollama-launch] section")
}
if !strings.Contains(content, `name = "Ollama"`) {
t.Error("missing model provider name")
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("appends profile to existing file without profile", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
existing := "[some_other_section]\nkey = \"value\"\n"
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexProfile(configPath); err != nil {
if err := writeCodexProfile(configPath, catalogPath); err != nil {
t.Fatal(err)
}
@@ -95,10 +111,11 @@ func TestWriteCodexProfile(t *testing.T) {
t.Run("replaces existing profile section", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n\n[model_providers.ollama-launch]\nname = \"Ollama\"\nbase_url = \"http://old:1234/v1/\"\n"
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexProfile(configPath); err != nil {
if err := writeCodexProfile(configPath, catalogPath); err != nil {
t.Fatal(err)
}
@@ -114,15 +131,160 @@ func TestWriteCodexProfile(t *testing.T) {
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
t.Errorf("expected exactly one [model_providers.ollama-launch] section, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("replaces equivalent quoted profile table", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
existing := "" +
`profile = "default"` + "\n\n" +
`[profiles."ollama-launch"]` + "\n" +
`openai_base_url = "http://old:1234/v1/"` + "\n\n" +
`[model_providers."ollama-launch"]` + "\n" +
`name = "Old"` + "\n" +
`base_url = "http://old:1234/v1/"` + "\n\n" +
`[profiles.default]` + "\n" +
`model = "gpt-5.5"` + "\n"
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexProfile(configPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, `profiles."ollama-launch"`) {
t.Fatalf("quoted profile table should be replaced, got:\n%s", content)
}
if strings.Contains(content, "old:1234") {
t.Fatalf("old URL was not replaced, got:\n%s", content)
}
if got := codexSectionStringValue(content, codexProfileHeader(), "model_provider"); got != codexProfileName {
t.Fatalf("profile model_provider = %q, want %q", got, codexProfileName)
}
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("rejects invalid existing toml without writing", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
existing := "profile = \n"
os.WriteFile(configPath, []byte(existing), 0o644)
err := writeCodexProfile(configPath)
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
}
data, _ := os.ReadFile(configPath)
if string(data) != existing {
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
}
})
t.Run("rejects malformed existing toml variants without writing", func(t *testing.T) {
tests := map[string]string{
"duplicate root key": "profile = \"default\"\nprofile = \"other\"\n",
"unterminated string": "model = \"gpt-5.5\n",
"bad table": "[profiles.ollama-launch\nmodel = \"llama3.2\"\n",
"duplicate table key": "[profiles.ollama-launch]\nmodel = \"a\"\nmodel = \"b\"\n",
}
for name, existing := range tests {
t.Run(name, func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
err := writeCodexProfile(configPath)
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
}
data, _ := os.ReadFile(configPath)
if string(data) != existing {
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
}
})
}
})
t.Run("backs up previous config before overwrite", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
existing := "# original-codex-backup-marker\n[profiles.default]\nmodel = \"gpt-5.5\"\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := writeCodexProfile(configPath); err != nil {
t.Fatal(err)
}
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), "original-codex-backup-marker")
})
t.Run("updates equivalent quoted root keys", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
existing := "" +
`"profile" = "default"` + "\n" +
`"model" = "gpt-5.5"` + "\n" +
`"model_provider" = "openai"` + "\n\n" +
`[profiles.default]` + "\n" +
`model = "gpt-5.5"` + "\n"
os.WriteFile(configPath, []byte(existing), 0o644)
err := writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
activate: true,
setRootModelConfig: true,
model: "llama3.2",
})
if err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
for key, want := range map[string]string{
"profile": codexProfileName,
"model": "llama3.2",
"model_provider": codexProfileName,
} {
if got := codexRootStringValue(content, key); got != want {
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
}
}
if strings.Contains(content, `"profile"`) || strings.Contains(content, `"model_provider"`) {
t.Fatalf("quoted root keys should be rewritten once, got:\n%s", content)
}
if err := codexValidateConfigText(content); err != nil {
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
}
})
t.Run("replaces profile while preserving following sections", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n[another_section]\nfoo = \"bar\"\n"
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexProfile(configPath); err != nil {
if err := writeCodexProfile(configPath, catalogPath); err != nil {
t.Fatal(err)
}
@@ -143,10 +305,11 @@ func TestWriteCodexProfile(t *testing.T) {
t.Run("appends newline to file not ending with newline", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
existing := "[other]\nkey = \"val\""
os.WriteFile(configPath, []byte(existing), 0o644)
if err := writeCodexProfile(configPath); err != nil {
if err := writeCodexProfile(configPath, catalogPath); err != nil {
t.Fatal(err)
}
@@ -166,8 +329,9 @@ func TestWriteCodexProfile(t *testing.T) {
t.Setenv("OLLAMA_HOST", "http://myhost:9999")
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
if err := writeCodexProfile(configPath); err != nil {
if err := writeCodexProfile(configPath, catalogPath); err != nil {
t.Fatal(err)
}
@@ -178,6 +342,26 @@ func TestWriteCodexProfile(t *testing.T) {
t.Errorf("expected custom host in URL, got:\n%s", content)
}
})
t.Run("uses connectable host for unspecified bind address", func(t *testing.T) {
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
if err := writeCodexProfile(configPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, "0.0.0.0") {
t.Fatalf("config should not write bind-only host, got:\n%s", content)
}
if !strings.Contains(content, "127.0.0.1:11434/v1/") {
t.Fatalf("expected connectable loopback URL, got:\n%s", content)
}
})
}
func TestEnsureCodexConfig(t *testing.T) {
@@ -185,7 +369,7 @@ func TestEnsureCodexConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
if err := ensureCodexConfig(); err != nil {
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
t.Fatal(err)
}
@@ -202,16 +386,25 @@ func TestEnsureCodexConfig(t *testing.T) {
if !strings.Contains(content, "openai_base_url") {
t.Error("missing openai_base_url key")
}
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
data, err = os.ReadFile(catalogPath)
if err != nil {
t.Fatalf("model.json not created: %v", err)
}
if !strings.Contains(string(data), `"slug": "llama3.2"`) {
t.Error("missing model catalog entry for selected model")
}
})
t.Run("is idempotent", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
if err := ensureCodexConfig(); err != nil {
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
t.Fatal(err)
}
if err := ensureCodexConfig(); err != nil {
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
t.Fatal(err)
}
@@ -227,3 +420,176 @@ func TestEnsureCodexConfig(t *testing.T) {
}
})
}
func assertBackupContains(t *testing.T, pattern, marker string) {
t.Helper()
backups, err := filepath.Glob(pattern)
if err != nil {
t.Fatal(err)
}
for _, backupPath := range backups {
data, err := os.ReadFile(backupPath)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(data), marker) {
return
}
}
t.Fatalf("backup matching %q with marker %q not found", pattern, marker)
}
func TestModelInfoContextLength(t *testing.T) {
tests := []struct {
name string
modelInfo map[string]any
want int
}{
{"float64 value", map[string]any{"qwen3_5_moe.context_length": float64(262144)}, 262144},
{"int value", map[string]any{"llama.context_length": 131072}, 131072},
{"no context_length key", map[string]any{"llama.embedding_length": float64(4096)}, 0},
{"empty map", map[string]any{}, 0},
{"nil map", nil, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, _ := modelInfoContextLength(tt.modelInfo)
if got != tt.want {
t.Errorf("modelInfoContextLength() = %d, want %d", got, tt.want)
}
})
}
}
func TestBuildCodexModelEntryContextWindow(t *testing.T) {
tests := []struct {
name string
model LaunchModel
envContextLen string
wantContext int
}{
{
name: "inventory context length as fallback",
model: LaunchModel{
Name: "llama3.2",
ContextLength: 131072,
Details: api.ModelDetails{Format: "gguf"},
},
wantContext: 131072,
},
{
name: "details context length is used when model context is empty",
model: LaunchModel{
Name: "llama3.2",
Details: api.ModelDetails{Format: "gguf", ContextLength: 131072},
},
wantContext: 131072,
},
{
name: "OLLAMA_CONTEXT_LENGTH overrides local gguf inventory context",
model: LaunchModel{
Name: "llama3.2",
ContextLength: 131072,
Details: api.ModelDetails{Format: "gguf"},
},
envContextLen: "64000",
wantContext: 64000,
},
{
name: "safetensors uses inventory context only",
model: LaunchModel{
Name: "llama3.2",
ContextLength: 131072,
Details: api.ModelDetails{Format: "safetensors"},
},
envContextLen: "64000",
wantContext: 131072,
},
{
name: "cloud model uses hardcoded limits",
model: LaunchModel{
Name: "qwen3.5:cloud",
ContextLength: 131072,
Details: api.ModelDetails{Format: "gguf"},
},
envContextLen: "64000",
wantContext: 262144,
},
{
name: "unknown cloud model without metadata uses fallback context",
model: LaunchModel{
Name: "deepseek-v4-pro:cloud",
},
envContextLen: "64000",
wantContext: codexFallbackContextWindow,
},
{
name: "vision capability without reasoning advertisement",
model: LaunchModel{
Name: "llama3.2",
ContextLength: 131072,
Details: api.ModelDetails{Format: "gguf"},
Capabilities: []modelpkg.Capability{modelpkg.CapabilityVision, modelpkg.CapabilityThinking},
},
wantContext: 131072,
},
{
name: "missing metadata uses fallback context",
model: LaunchModel{Name: "llama3.2"},
wantContext: codexFallbackContextWindow,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envContextLen != "" {
t.Setenv("OLLAMA_CONTEXT_LENGTH", tt.envContextLen)
} else {
t.Setenv("OLLAMA_CONTEXT_LENGTH", "")
}
entry := buildCodexModelEntry(tt.model)
gotContext, _ := entry["context_window"].(int)
if gotContext != tt.wantContext {
t.Errorf("context_window = %d, want %d", gotContext, tt.wantContext)
}
if tt.name == "vision capability without reasoning advertisement" {
modalities, _ := entry["input_modalities"].([]string)
if !slices.Contains(modalities, "image") {
t.Error("expected image in input_modalities")
}
levels, _ := entry["supported_reasoning_levels"].([]any)
if len(levels) != 0 {
t.Errorf("supported_reasoning_levels length = %d, want 0", len(levels))
}
if got, _ := entry["supports_reasoning_summaries"].(bool); got {
t.Error("supports_reasoning_summaries = true, want false")
}
}
if tt.name == "cloud model uses hardcoded limits" {
truncationPolicy, _ := entry["truncation_policy"].(map[string]any)
if mode, _ := truncationPolicy["mode"].(string); mode != "tokens" {
t.Errorf("truncation_policy mode = %q, want %q", mode, "tokens")
}
}
requiredKeys := []string{"slug", "display_name", "shell_type"}
for _, key := range requiredKeys {
if _, ok := entry[key]; !ok {
t.Errorf("missing required key %q", key)
}
}
if _, ok := entry["apply_patch_tool_type"]; ok {
t.Error("apply_patch_tool_type should be omitted so Codex CLI defaults can handle schema changes")
}
if _, err := json.Marshal(entry); err != nil {
t.Errorf("entry is not JSON serializable: %v", err)
}
})
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ func (c *Copilot) findPath() (string, error) {
return fallback, nil
}
func (c *Copilot) Run(model string, args []string) error {
func (c *Copilot) Run(model string, _ []LaunchModel, args []string) error {
copilotPath, err := c.findPath()
if err != nil {
return fmt.Errorf("copilot is not installed, install from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli")
+9 -11
View File
@@ -40,7 +40,7 @@ type modelEntry struct {
func (d *Droid) String() string { return "Droid" }
func (d *Droid) Run(model string, args []string) error {
func (d *Droid) Run(model string, _ []LaunchModel, args []string) error {
if _, err := exec.LookPath("droid"); err != nil {
return fmt.Errorf("droid is not installed, install from https://docs.factory.ai/cli/getting-started/quickstart")
}
@@ -64,7 +64,7 @@ func (d *Droid) Paths() []string {
return nil
}
func (d *Droid) Edit(models []string) error {
func (d *Droid) Edit(models []LaunchModel) error {
if len(models) == 0 {
return nil
}
@@ -99,7 +99,7 @@ func (d *Droid) Edit(models []string) error {
return fileutil.WriteWithBackup(settingsPath, data, "droid")
}
func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []string) map[string]any {
func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []LaunchModel) map[string]any {
// Keep only non-Ollama models from the raw map (preserves extra fields)
// Rebuild Ollama models
var nonOllamaModels []any
@@ -119,20 +119,18 @@ func updateDroidSettings(settingsMap map[string]any, settings droidSettings, mod
var defaultModelID string
for i, model := range models {
maxOutput := 64000
if isCloudModelName(model) {
if l, ok := lookupCloudModelLimit(model); ok {
maxOutput = l.Output
}
if model.MaxOutputTokens > 0 {
maxOutput = model.MaxOutputTokens
}
modelID := fmt.Sprintf("custom:%s-%d", model, i)
modelID := fmt.Sprintf("custom:%s-%d", model.Name, i)
newModels = append(newModels, modelEntry{
Model: model,
DisplayName: model,
Model: model.Name,
DisplayName: model.Name,
BaseURL: envconfig.Host().String() + "/v1",
APIKey: "ollama",
Provider: "generic-chat-completion-api",
MaxOutputTokens: maxOutput,
SupportsImages: false,
SupportsImages: model.HasCapability("vision"),
ID: modelID,
Index: i,
})
+35 -35
View File
@@ -63,7 +63,7 @@ func TestDroidEdit(t *testing.T) {
t.Run("fresh install creates models with sequential indices", func(t *testing.T) {
cleanup()
if err := d.Edit([]string{"model-a", "model-b"}); err != nil {
if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil {
t.Fatal(err)
}
@@ -99,7 +99,7 @@ func TestDroidEdit(t *testing.T) {
t.Run("sets sessionDefaultSettings.model to first model ID", func(t *testing.T) {
cleanup()
if err := d.Edit([]string{"model-a", "model-b"}); err != nil {
if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil {
t.Fatal(err)
}
@@ -116,10 +116,10 @@ func TestDroidEdit(t *testing.T) {
t.Run("re-indexes when models removed", func(t *testing.T) {
cleanup()
// Add three models
d.Edit([]string{"model-a", "model-b", "model-c"})
d.Edit(testLaunchModels("model-a", "model-b", "model-c"))
// Remove middle model
d.Edit([]string{"model-a", "model-c"})
d.Edit(testLaunchModels("model-a", "model-c"))
settings := readSettings()
models := getCustomModels(settings)
@@ -155,7 +155,7 @@ func TestDroidEdit(t *testing.T) {
]
}`), 0o644)
d.Edit([]string{"model-a"})
d.Edit(testLaunchModels("model-a"))
settings := readSettings()
models := getCustomModels(settings)
@@ -184,7 +184,7 @@ func TestDroidEdit(t *testing.T) {
"sessionDefaultSettings": {"autonomyMode": "auto-high"}
}`), 0o644)
d.Edit([]string{"model-a"})
d.Edit(testLaunchModels("model-a"))
settings := readSettings()
@@ -203,7 +203,7 @@ func TestDroidEdit(t *testing.T) {
t.Run("required fields present", func(t *testing.T) {
cleanup()
d.Edit([]string{"test-model"})
d.Edit(testLaunchModels("test-model"))
settings := readSettings()
models := getCustomModels(settings)
@@ -239,7 +239,7 @@ func TestDroidEdit(t *testing.T) {
"sessionDefaultSettings": {"reasoningEffort": "off"}
}`), 0o644)
d.Edit([]string{"model-a"})
d.Edit(testLaunchModels("model-a"))
settings := readSettings()
session := settings["sessionDefaultSettings"].(map[string]any)
@@ -256,7 +256,7 @@ func TestDroidEdit(t *testing.T) {
"sessionDefaultSettings": {"reasoningEffort": "high"}
}`), 0o644)
d.Edit([]string{"model-a"})
d.Edit(testLaunchModels("model-a"))
settings := readSettings()
session := settings["sessionDefaultSettings"].(map[string]any)
@@ -281,7 +281,7 @@ func TestDroidEdit_CorruptedJSON(t *testing.T) {
os.WriteFile(settingsPath, []byte(`{corrupted json content`), 0o644)
// Corrupted JSON should return an error so user knows something is wrong
err := d.Edit([]string{"model-a"})
err := d.Edit(testLaunchModels("model-a"))
if err == nil {
t.Fatal("expected error for corrupted JSON, got nil")
}
@@ -306,7 +306,7 @@ func TestDroidEdit_WrongTypeCustomModels(t *testing.T) {
os.WriteFile(settingsPath, []byte(`{"customModels": "not an array"}`), 0o644)
// Should not panic - wrong type should be handled gracefully
err := d.Edit([]string{"model-a"})
err := d.Edit(testLaunchModels("model-a"))
if err != nil {
t.Fatalf("Edit failed with wrong type customModels: %v", err)
}
@@ -338,7 +338,7 @@ func TestDroidEdit_EmptyModels(t *testing.T) {
os.WriteFile(settingsPath, []byte(originalContent), 0o644)
// Empty models should be no-op
err := d.Edit([]string{})
err := d.Edit(testLaunchModels())
if err != nil {
t.Fatalf("Edit with empty models failed: %v", err)
}
@@ -359,7 +359,7 @@ func TestDroidEdit_DuplicateModels(t *testing.T) {
settingsPath := filepath.Join(settingsDir, "settings.json")
// Add same model twice
err := d.Edit([]string{"model-a", "model-a"})
err := d.Edit(testLaunchModels("model-a", "model-a"))
if err != nil {
t.Fatalf("Edit with duplicates failed: %v", err)
}
@@ -388,7 +388,7 @@ func TestDroidEdit_MalformedModelEntry(t *testing.T) {
// Model entry is a string instead of a map
os.WriteFile(settingsPath, []byte(`{"customModels": ["not a map", 123]}`), 0o644)
err := d.Edit([]string{"model-a"})
err := d.Edit(testLaunchModels("model-a"))
if err != nil {
t.Fatalf("Edit with malformed entries failed: %v", err)
}
@@ -415,7 +415,7 @@ func TestDroidEdit_WrongTypeSessionSettings(t *testing.T) {
// sessionDefaultSettings is a string instead of map
os.WriteFile(settingsPath, []byte(`{"sessionDefaultSettings": "not a map"}`), 0o644)
err := d.Edit([]string{"model-a"})
err := d.Edit(testLaunchModels("model-a"))
if err != nil {
t.Fatalf("Edit with wrong type sessionDefaultSettings failed: %v", err)
}
@@ -490,7 +490,7 @@ func TestDroidEdit_RoundTrip(t *testing.T) {
os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644)
// Edit with new models
if err := d.Edit([]string{"llama3", "mistral"}); err != nil {
if err := d.Edit(testLaunchModels("llama3", "mistral")); err != nil {
t.Fatal(err)
}
@@ -615,7 +615,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -660,7 +660,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"llama3"}); err != nil {
if err := d.Edit(testLaunchModels("llama3")); err != nil {
t.Fatal(err)
}
@@ -715,10 +715,10 @@ func TestDroidEdit_Idempotent(t *testing.T) {
os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644)
// Edit twice with same models
d.Edit([]string{"llama3", "mistral"})
d.Edit(testLaunchModels("llama3", "mistral"))
firstData, _ := os.ReadFile(settingsPath)
d.Edit([]string{"llama3", "mistral"})
d.Edit(testLaunchModels("llama3", "mistral"))
secondData, _ := os.ReadFile(settingsPath)
// Results should be identical
@@ -744,7 +744,7 @@ func TestDroidEdit_MultipleConsecutiveEdits(t *testing.T) {
if i%2 == 0 {
models = []string{"model-x", "model-y", "model-z"}
}
if err := d.Edit(models); err != nil {
if err := d.Edit(launchModelsFromNames(models)); err != nil {
t.Fatalf("edit %d failed: %v", i, err)
}
}
@@ -803,7 +803,7 @@ func TestDroidEdit_UnicodeAndSpecialCharacters(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -845,7 +845,7 @@ func TestDroidEdit_LargeNumbers(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -889,7 +889,7 @@ func TestDroidEdit_EmptyAndNullValues(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -943,7 +943,7 @@ func TestDroidEdit_DeeplyNestedStructures(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -988,7 +988,7 @@ func TestDroidEdit_ModelNamesWithSpecialCharacters(t *testing.T) {
"model_with_underscores",
}
if err := d.Edit(specialModels); err != nil {
if err := d.Edit(launchModelsFromNames(specialModels)); err != nil {
t.Fatal(err)
}
@@ -1025,7 +1025,7 @@ func TestDroidEdit_MissingCustomModelsKey(t *testing.T) {
t.Fatal(err)
}
settings = updateDroidSettings(settings, settingsStruct, []string{"model-a"})
settings = updateDroidSettings(settings, settingsStruct, testLaunchModels("model-a"))
// Original fields preserved
if settings["diffMode"] != "github" {
@@ -1062,7 +1062,7 @@ func TestDroidEdit_NullCustomModels(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -1090,7 +1090,7 @@ func TestDroidEdit_MinifiedJSON(t *testing.T) {
original := `{"diffMode":"github","enableHooks":true,"hooks":{"imported":["cmd1","cmd2"]},"customModels":[],"sessionDefaultSettings":{}}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -1120,7 +1120,7 @@ func TestDroidEdit_CreatesDirectoryIfMissing(t *testing.T) {
t.Fatal("directory should not exist before test")
}
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -1157,7 +1157,7 @@ func TestDroidEdit_PreservesFileAfterError(t *testing.T) {
os.WriteFile(settingsPath, []byte(original), 0o644)
// Empty models list is a no-op, should not modify file
d.Edit([]string{})
d.Edit(testLaunchModels())
data, _ := os.ReadFile(settingsPath)
if string(data) != original {
@@ -1181,7 +1181,7 @@ func TestDroidEdit_BackupCreated(t *testing.T) {
original := fmt.Sprintf(`{"diffMode": "%s", "customModels": [], "sessionDefaultSettings": {}}`, uniqueMarker)
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -1231,7 +1231,7 @@ func TestDroidEdit_LargeNumberOfModels(t *testing.T) {
models = append(models, fmt.Sprintf("model-%d", i))
}
if err := d.Edit(models); err != nil {
if err := d.Edit(launchModelsFromNames(models)); err != nil {
t.Fatal(err)
}
@@ -1261,7 +1261,7 @@ func TestDroidEdit_LocalModelDefaultMaxOutput(t *testing.T) {
settingsDir := filepath.Join(tmpDir, ".factory")
settingsPath := filepath.Join(settingsDir, "settings.json")
if err := d.Edit([]string{"llama3.2"}); err != nil {
if err := d.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
@@ -1312,7 +1312,7 @@ func TestDroidEdit_ArraysWithMixedTypes(t *testing.T) {
}`
os.WriteFile(settingsPath, []byte(original), 0o644)
if err := d.Edit([]string{"model-a"}); err != nil {
if err := d.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
+1 -1
View File
@@ -65,7 +65,7 @@ type Hermes struct{}
func (h *Hermes) String() string { return "Hermes Agent" }
func (h *Hermes) Run(_ string, args []string) error {
func (h *Hermes) Run(_ string, _ []LaunchModel, args []string) error {
// Hermes reads its primary model from config.yaml. launch configures that
// default model ahead of time so we can keep runtime invocation simple and
// still let Hermes discover additional models later via its own UX.
+7 -7
View File
@@ -552,7 +552,7 @@ func TestHermesRunPassthroughArgs(t *testing.T) {
}
h := &Hermes{}
if err := h.Run("", []string{"--continue"}); err != nil {
if err := h.Run("", nil, []string{"--continue"}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@@ -603,7 +603,7 @@ fi
}
h := &Hermes{}
if err := h.Run("", nil); err != nil {
if err := h.Run("", nil, nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@@ -655,10 +655,10 @@ func TestHermesRun_SetUpLaterRepromptsOnLaterLaunches(t *testing.T) {
}
h := &Hermes{}
if err := h.Run("", nil); err != nil {
if err := h.Run("", nil, nil); err != nil {
t.Fatalf("first Run returned error: %v", err)
}
if err := h.Run("", nil); err != nil {
if err := h.Run("", nil, nil); err != nil {
t.Fatalf("second Run returned error: %v", err)
}
@@ -713,7 +713,7 @@ func TestHermesRun_SkipsMessagingPromptWhenConfigured(t *testing.T) {
}
h := &Hermes{}
if err := h.Run("", nil); err != nil {
if err := h.Run("", nil, nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@@ -753,7 +753,7 @@ func TestHermesRun_SkipsMessagingPromptWithYesPolicy(t *testing.T) {
}
h := &Hermes{}
if err := h.Run("", nil); err != nil {
if err := h.Run("", nil, nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@@ -798,7 +798,7 @@ fi
}
h := &Hermes{}
err := h.Run("", nil)
err := h.Run("", nil, nil)
if err == nil {
t.Fatal("expected messaging setup failure")
}
+49 -11
View File
@@ -25,7 +25,7 @@ type stubEditorRunner struct {
editErr error
}
func (s *stubEditorRunner) Run(model string, args []string) error {
func (s *stubEditorRunner) Run(model string, _ []LaunchModel, args []string) error {
s.ranModel = model
return nil
}
@@ -34,11 +34,11 @@ func (s *stubEditorRunner) String() string { return "StubEditor" }
func (s *stubEditorRunner) Paths() []string { return nil }
func (s *stubEditorRunner) Edit(models []string) error {
func (s *stubEditorRunner) Edit(models []LaunchModel) error {
if s.editErr != nil {
return s.editErr
}
cloned := append([]string(nil), models...)
cloned := launchModelNames(models)
s.edited = append(s.edited, cloned)
return nil
}
@@ -58,6 +58,9 @@ func TestIntegrationLookup(t *testing.T) {
{"claude desktop", "claude-desktop", true, "Claude Desktop"},
{"claude desktop alias", "claude-app", true, "Claude Desktop"},
{"codex", "codex", true, "Codex"},
{"codex app", "codex-app", true, "Codex App"},
{"codex app desktop alias", "codex-desktop", true, "Codex App"},
{"codex app gui alias", "codex-gui", true, "Codex App"},
{"kimi", "kimi", true, "Kimi Code CLI"},
{"droid", "droid", true, "Droid"},
{"opencode", "opencode", true, "OpenCode"},
@@ -80,7 +83,7 @@ func TestIntegrationLookup(t *testing.T) {
}
func TestIntegrationRegistry(t *testing.T) {
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "kimi", "droid", "opencode", "hermes", "pool"}
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "pool"}
for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) {
r, ok := integrations[name]
@@ -203,7 +206,7 @@ func TestAllIntegrations_HaveRequiredMethods(t *testing.T) {
if displayName == "" {
t.Error("String() should not return empty")
}
var _ func(string, []string) error = r.Run
var _ func(string, []LaunchModel, []string) error = r.Run
})
}
}
@@ -478,11 +481,11 @@ func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
func TestBuildModelList_PreservesRecommendationRequiredPlanForExistingCloudModel(t *testing.T) {
recommendations := []ModelItem{
{
Name: "glm-5:cloud",
Description: "Reasoning and code generation",
Recommended: true,
RequiredPlan: "pro",
ContextLength: 202_752,
Name: "glm-5:cloud",
Description: "Reasoning and code generation",
Recommended: true,
RequiredPlan: "pro",
Details: api.ModelDetails{ContextLength: 202_752},
},
}
existing := []modelInfo{{Name: "glm-5:cloud", Remote: true}}
@@ -863,7 +866,7 @@ func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) {
}
editor := &stubEditorRunner{editErr: errors.New("boom")}
err := prepareEditorIntegration("droid", editor, []string{"new-model"})
err := prepareEditorIntegration("droid", editor, testLaunchModels("new-model"))
if err == nil || !strings.Contains(err.Error(), "setup failed") {
t.Fatalf("expected setup failure, got %v", err)
}
@@ -1737,6 +1740,11 @@ func TestIntegration_InstallHint(t *testing.T) {
input: "codex",
wantURL: "https://developers.openai.com/codex/cli/",
},
{
name: "codex app has hint",
input: "codex-app",
wantURL: "https://developers.openai.com/codex/quickstart",
},
{
name: "openclaw has hint",
input: "openclaw",
@@ -1813,11 +1821,38 @@ func TestListIntegrationInfos(t *testing.T) {
}
want = filtered
}
if codexAppSupported() != nil {
filtered := make([]string, 0, len(want))
for _, name := range want {
if name != "codex-app" {
filtered = append(filtered, name)
}
}
want = filtered
}
if diff := compareStrings(got, want); diff != "" {
t.Fatalf("launcher integration order mismatch: %s", diff)
}
})
t.Run("prioritizes primary launcher integrations", func(t *testing.T) {
got := make([]string, 0, len(infos))
for _, info := range infos {
got = append(got, info.Name)
}
wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw"}
if codexAppSupported() != nil {
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode"}
}
if len(got) < len(wantPrefix) {
t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got)
}
if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" {
t.Fatalf("unexpected primary launcher order: %s", diff)
}
})
t.Run("all fields populated", func(t *testing.T) {
for _, info := range infos {
if info.Name == "" {
@@ -1831,6 +1866,9 @@ func TestListIntegrationInfos(t *testing.T) {
t.Run("includes known integrations", func(t *testing.T) {
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
if codexAppSupported() == nil {
known["codex-app"] = false
}
if poolsideGOOS != "windows" {
known["pool"] = false
}
+1 -1
View File
@@ -36,7 +36,7 @@ func (k *Kimi) args(config string, extra []string) []string {
return args
}
func (k *Kimi) Run(model string, args []string) error {
func (k *Kimi) Run(model string, _ []LaunchModel, args []string) error {
if strings.TrimSpace(model) == "" {
return fmt.Errorf("model is required")
}
+2 -2
View File
@@ -307,7 +307,7 @@ func TestKimiRun_RejectsConflictingArgsBeforeInstall(t *testing.T) {
}
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
err := k.Run("llama3.2", []string{"--model", "other"})
err := k.Run("llama3.2", nil, []string{"--model", "other"})
if err == nil || !strings.Contains(err.Error(), "--model") {
t.Fatalf("expected conflict error mentioning --model, got %v", err)
}
@@ -337,7 +337,7 @@ exit 0
t.Setenv("OLLAMA_HOST", srv.URL)
k := &Kimi{}
if err := k.Run("llama3.2", []string{"--quiet", "--print"}); err != nil {
if err := k.Run("llama3.2", nil, []string{"--quiet", "--print"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
+74 -66
View File
@@ -12,6 +12,7 @@ import (
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/config"
modelpkg "github.com/ollama/ollama/types/model"
"github.com/spf13/cobra"
"golang.org/x/term"
)
@@ -137,16 +138,17 @@ var isInteractiveSession = func() bool {
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
}
// Runner executes a model with an integration.
// Runner executes an integration with the selected model and its resolved
// launch metadata. models is ordered with the primary model first.
type Runner interface {
Run(model string, args []string) error
Run(model string, models []LaunchModel, args []string) error
String() string
}
// Editor can edit config files for integrations that support model configuration.
type Editor interface {
Paths() []string
Edit(models []string) error
Edit(models []LaunchModel) error
Models() []string
}
@@ -165,7 +167,7 @@ type ManagedSingleModel interface {
// ManagedModelListConfigurer lets managed single-model integrations receive
// the launcher's model list while still preserving one primary selected model.
type ManagedModelListConfigurer interface {
ConfigureWithModels(primary string, models []string) error
ConfigureWithModels(primary string, models []LaunchModel) error
}
// ManagedAutodiscoveryIntegration is for managed integrations that do not need
@@ -239,24 +241,18 @@ type SupportedIntegration interface {
Supported() error
}
type modelInfo struct {
Name string
Remote bool
ToolCapable bool
}
// ModelInfo re-exports launcher model inventory details for callers.
type ModelInfo = modelInfo
// ModelItem represents model metadata before selector-only UI state is derived.
type ModelItem struct {
Name string
Description string
Recommended bool
VRAMBytes int64
ContextLength int
MaxOutputTokens int
RequiredPlan string
ToolCapable bool
Capabilities []modelpkg.Capability
Size int64
Details api.ModelDetails
}
// SelectionItem represents a model row after launch has derived selector-only UI state.
@@ -285,22 +281,25 @@ Flags and extra arguments require an integration name.
Supported integrations:
claude Claude Code
cline Cline
codex-app Codex App (aliases: codex-desktop, codex-gui)
hermes Hermes Agent
openclaw OpenClaw (aliases: clawdbot, moltbot)
opencode OpenCode
codex Codex
copilot Copilot CLI (aliases: copilot-cli)
droid Droid
hermes Hermes Agent
kimi Kimi Code CLI
opencode OpenCode
openclaw OpenClaw (aliases: clawdbot, moltbot)
pi Pi
pool Pool
cline Cline
vscode VS Code (aliases: code)
Examples:
ollama launch
ollama launch claude
ollama launch claude --model <model>
ollama launch codex-app
ollama launch codex-app --restore
ollama launch hermes
ollama launch droid --config (does not auto-launch)
ollama launch codex -- -p myprofile (pass extra args to integration)
@@ -406,8 +405,7 @@ func launchCommandIsClaudeDesktop(name string) bool {
type launcherClient struct {
apiClient *api.Client
modelInventory []ModelInfo
inventoryLoaded bool
inventory *modelInventory
recommendationsLoaded bool
recommendationItems []ModelItem
accountState *AccountState
@@ -424,10 +422,18 @@ func newLauncherClient(policy LaunchPolicy) (*launcherClient, error) {
return &launcherClient{
apiClient: apiClient,
inventory: newModelInventory(apiClient),
policy: policy,
}, nil
}
func (c *launcherClient) modelInventory() *modelInventory {
if c.inventory == nil {
c.inventory = newModelInventory(c.apiClient)
}
return c.inventory
}
// BuildLauncherState returns the launch-owned root launcher menu snapshot.
func BuildLauncherState(ctx context.Context) (*LauncherState, error) {
launchClient, err := newLauncherClient(defaultLaunchPolicy(isInteractiveSession(), false))
@@ -549,7 +555,7 @@ func prepareIntegrationLaunch(name string, policy LaunchPolicy) (*launcherClient
}
func (c *launcherClient) buildLauncherState(ctx context.Context) (*LauncherState, error) {
_ = c.loadModelInventoryOnce(ctx)
_, _ = c.modelInventory().Load(ctx)
state := &LauncherState{
LastSelection: config.LastSelection(),
@@ -723,7 +729,7 @@ func (c *launcherClient) launchSingleIntegration(ctx context.Context, name strin
}
}
return launchAfterConfiguration(name, runner, target, req)
return launchAfterConfiguration(name, runner, target, c.resolveRunModels(ctx, []string{target}), req)
}
func (c *launcherClient) launchEditorIntegration(ctx context.Context, name string, runner Runner, editor Editor, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
@@ -745,13 +751,17 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
return nil
}
var launchModels []LaunchModel
if (needsConfigure || req.ModelOverride != "") && !savedMatchesModels(saved, models) {
if err := prepareEditorIntegration(name, editor, models); err != nil {
launchModels = c.modelInventory().Resolve(ctx, models)
if err := prepareEditorIntegration(name, editor, launchModels); err != nil {
return err
}
} else {
launchModels = c.resolveRunModels(ctx, models)
}
return launchAfterConfiguration(name, runner, models[0], req)
return launchAfterConfiguration(name, runner, models[0], launchModels, req)
}
func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, name string, runner Runner, managed ManagedSingleModel, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
@@ -769,12 +779,18 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
return nil
}
if needsConfigure || req.ModelOverride != "" || (current != "" && target != current) || !savedMatchesModels(saved, []string{target}) {
// current is the live managed app config; target may come from saved launch
// state. Rewrite when the live config is missing or has drifted so the app
// config converges with the model which launch is about to use.
liveConfigMissing := current == ""
liveConfigDrifted := current != "" && target != current
configured := false
if needsConfigure || req.ModelOverride != "" || liveConfigMissing || liveConfigDrifted || !savedMatchesModels(saved, []string{target}) {
configureModels, err := c.managedSingleConfigureModels(ctx, managed, target)
if err != nil {
return err
}
if err := prepareManagedSingleIntegration(name, managed, target, configureModels); err != nil {
if err := prepareManagedSingleIntegration(name, managed, target, c.modelInventory().Resolve(ctx, configureModels)); err != nil {
return err
}
if refresher, ok := managed.(ManagedRuntimeRefresher); ok {
@@ -782,6 +798,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
return err
}
}
configured = true
}
if !managedIntegrationOnboarded(saved, managed) {
@@ -793,11 +810,17 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
}
}
if configured {
if !printConfigurationSuccess(managed) {
printRestoreHint(managed)
}
}
if req.ConfigureOnly {
return nil
}
return runIntegration(runner, target, req.ExtraArgs)
return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
}
func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Context, name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
@@ -840,7 +863,7 @@ func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Conte
return nil
}
return runIntegration(runner, target, req.ExtraArgs)
return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
}
func (c *launcherClient) managedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration) bool {
@@ -941,7 +964,7 @@ func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, run
}
}
if needsConfigure {
if needsConfigure && req.ModelOverride == "" {
selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness)
if err != nil {
return "", false, err
@@ -1092,13 +1115,14 @@ func runMultiSelector(title string, items []SelectionItem, preChecked []string,
}
func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) {
if err := c.loadModelInventoryOnce(ctx); err != nil {
inventory, err := c.modelInventory().Load(ctx)
if err != nil {
return nil, nil, err
}
recommendations := c.recommendations(ctx)
cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
items, orderedChecked, _, _ := buildModelListWithRecommendations(c.modelInventory, recommendations, preChecked, current)
items, orderedChecked, _, _ := buildModelListWithRecommendations(inventory, recommendations, preChecked, current)
if cloudDisabled {
items = filterCloudItems(items)
orderedChecked = c.filterDisabledCloudModels(ctx, orderedChecked)
@@ -1163,9 +1187,11 @@ func (c *launcherClient) requestRecommendations(ctx context.Context) ([]ModelIte
Description: description,
Recommended: true,
VRAMBytes: rec.VRAMBytes,
ContextLength: rec.ContextLength,
MaxOutputTokens: rec.MaxOutputTokens,
RequiredPlan: strings.TrimSpace(rec.RequiredPlan),
Details: api.ModelDetails{
ContextLength: rec.ContextLength,
},
})
}
@@ -1286,10 +1312,11 @@ func (c *launcherClient) filterDisabledCloudModels(ctx context.Context, models [
}
func (c *launcherClient) savedModelUsable(ctx context.Context, name string) (bool, error) {
if err := c.loadModelInventoryOnce(ctx); err != nil {
inventory, err := c.modelInventory().Load(ctx)
if err != nil {
return c.showBasedModelUsable(ctx, name)
}
return c.singleModelUsable(ctx, name), nil
return c.singleModelUsable(ctx, name, inventory), nil
}
func (c *launcherClient) showBasedModelUsable(ctx context.Context, name string) (bool, error) {
@@ -1315,7 +1342,7 @@ func (c *launcherClient) showBasedModelUsable(ctx context.Context, name string)
return true, nil
}
func (c *launcherClient) singleModelUsable(ctx context.Context, name string) bool {
func (c *launcherClient) singleModelUsable(ctx context.Context, name string, inventory []LaunchModel) bool {
if name == "" {
return false
}
@@ -1323,11 +1350,11 @@ func (c *launcherClient) singleModelUsable(ctx context.Context, name string) boo
cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
return !cloudDisabled
}
return c.hasLocalModel(name)
return hasLocalModel(inventory, name)
}
func (c *launcherClient) hasLocalModel(name string) bool {
for _, model := range c.modelInventory {
func hasLocalModel(inventory []LaunchModel, name string) bool {
for _, model := range inventory {
if model.Remote {
continue
}
@@ -1338,37 +1365,18 @@ func (c *launcherClient) hasLocalModel(name string) bool {
return false
}
func (c *launcherClient) loadModelInventoryOnce(ctx context.Context) error {
if c.inventoryLoaded {
return nil
}
resp, err := c.apiClient.List(ctx)
if err != nil {
return err
}
c.modelInventory = c.modelInventory[:0]
for _, model := range resp.Models {
c.modelInventory = append(c.modelInventory, ModelInfo{
Name: model.Name,
Remote: model.RemoteModel != "",
})
}
cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
if cloudDisabled {
c.modelInventory = filterCloudModels(c.modelInventory)
}
c.inventoryLoaded = true
return nil
func (c *launcherClient) resolveRunModels(ctx context.Context, models []string) []LaunchModel {
return c.modelInventory().Resolve(ctx, models)
}
func runIntegration(runner Runner, modelName string, args []string) error {
return runner.Run(modelName, args)
func runIntegration(runner Runner, modelName string, models []LaunchModel, args []string) error {
if len(models) == 0 && modelName != "" {
models = launchModelsFromNames([]string{modelName})
}
return runner.Run(modelName, models, args)
}
func launchAfterConfiguration(name string, runner Runner, model string, req IntegrationLaunchRequest) error {
func launchAfterConfiguration(name string, runner Runner, model string, models []LaunchModel, req IntegrationLaunchRequest) error {
if req.ConfigureOnly {
launch, err := ConfirmPrompt(fmt.Sprintf("Launch %s now?", runner))
if err != nil {
@@ -1381,7 +1389,7 @@ func launchAfterConfiguration(name string, runner Runner, model string, req Inte
if err := EnsureIntegrationInstalled(name, runner); err != nil {
return err
}
return runIntegration(runner, model, req.ExtraArgs)
return runIntegration(runner, model, models, req.ExtraArgs)
}
func loadStoredIntegrationConfig(name string) (*config.IntegrationConfig, error) {
+235 -17
View File
@@ -11,6 +11,7 @@ import (
"runtime"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
@@ -24,7 +25,7 @@ type launcherEditorRunner struct {
ranModel string
}
func (r *launcherEditorRunner) Run(model string, args []string) error {
func (r *launcherEditorRunner) Run(model string, _ []LaunchModel, args []string) error {
r.ranModel = model
return nil
}
@@ -33,8 +34,8 @@ func (r *launcherEditorRunner) String() string { return "LauncherEditor" }
func (r *launcherEditorRunner) Paths() []string { return r.paths }
func (r *launcherEditorRunner) Edit(models []string) error {
r.edited = append(r.edited, append([]string(nil), models...))
func (r *launcherEditorRunner) Edit(models []LaunchModel) error {
r.edited = append(r.edited, launchModelNames(models))
return nil
}
@@ -44,7 +45,7 @@ type launcherSingleRunner struct {
ranModel string
}
func (r *launcherSingleRunner) Run(model string, args []string) error {
func (r *launcherSingleRunner) Run(model string, _ []LaunchModel, args []string) error {
r.ranModel = model
return nil
}
@@ -68,18 +69,21 @@ func (r *launcherRestorableRunner) RestoreSuccessMessage() string {
}
type launcherManagedRunner struct {
paths []string
currentModel string
configured []string
ranModel string
onboarded bool
onboardCalls int
onboardingComplete bool
refreshCalls int
refreshErr error
paths []string
currentModel string
configured []string
ranModel string
onboarded bool
onboardCalls int
onboardingComplete bool
refreshCalls int
refreshErr error
restoreHint string
configSuccessMessage string
skipModelReadiness bool
}
func (r *launcherManagedRunner) Run(model string, args []string) error {
func (r *launcherManagedRunner) Run(model string, _ []LaunchModel, args []string) error {
r.ranModel = model
return nil
}
@@ -110,6 +114,14 @@ func (r *launcherManagedRunner) RefreshRuntimeAfterConfigure() error {
return r.refreshErr
}
func (r *launcherManagedRunner) RestoreHint() string { return r.restoreHint }
func (r *launcherManagedRunner) ConfigurationSuccessMessage() string {
return r.configSuccessMessage
}
func (r *launcherManagedRunner) SkipModelReadiness() bool { return r.skipModelReadiness }
type launcherHeadlessManagedRunner struct {
launcherManagedRunner
}
@@ -121,8 +133,8 @@ type launcherManagedListRunner struct {
configuredModelLists [][]string
}
func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []string) error {
r.configuredModelLists = append(r.configuredModelLists, append([]string(nil), models...))
func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []LaunchModel) error {
r.configuredModelLists = append(r.configuredModelLists, launchModelNames(models))
return r.Configure(primary)
}
@@ -480,6 +492,116 @@ func TestLaunchIntegration_ManagedSingleIntegrationConfigOnlySkipsFinalRun(t *te
}
}
func TestLaunchIntegration_ManagedSingleIntegrationPrintsConfigurationSuccessAfterConfigure(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
runner := &launcherManagedRunner{
configSuccessMessage: "configured successfully\nrestore via success message",
restoreHint: "run restore command",
skipModelReadiness: true,
}
withIntegrationOverride(t, "stubmanaged", runner)
stderr := captureStderr(t, func() {
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "stubmanaged",
ModelOverride: "gemma4",
ForceConfigure: true,
ConfigureOnly: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
})
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
t.Fatalf("configured models mismatch: %s", diff)
}
if !strings.Contains(stderr, "configured successfully") {
t.Fatalf("expected configuration success in stderr, got %q", stderr)
}
if !strings.Contains(stderr, "restore via success message") {
t.Fatalf("expected restore guidance in configuration success, got %q", stderr)
}
if strings.Contains(stderr, "run restore command") {
t.Fatalf("restore hint should not print separately after configure, got %q", stderr)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationDoesNotPrintRestoreHintWhenUnchanged(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
runner := &launcherManagedRunner{
currentModel: "gemma4",
onboardingComplete: true,
configSuccessMessage: "configured successfully",
restoreHint: "run restore command",
skipModelReadiness: true,
}
withIntegrationOverride(t, "stubmanaged", runner)
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
t.Fatalf("failed to save managed integration config: %v", err)
}
if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil {
t.Fatalf("failed to mark integration onboarded: %v", err)
}
stderr := captureStderr(t, func() {
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
})
if len(runner.configured) != 0 {
t.Fatalf("expected Configure to be skipped when saved matches, got %v", runner.configured)
}
if strings.Contains(stderr, "configured successfully") {
t.Fatalf("configuration success should not print when config is unchanged, got %q", stderr)
}
if strings.Contains(stderr, "run restore command") {
t.Fatalf("restore hint should not print when config is unchanged, got %q", stderr)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationForceConfigureUsesModelOverride(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
runner := &launcherManagedRunner{
paths: nil,
skipModelReadiness: true,
}
withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
return "", fmt.Errorf("selector should not run with an explicit model override")
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "stubmanaged",
ModelOverride: "gemma4",
ForceConfigure: true,
ConfigureOnly: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
t.Fatalf("configured models mismatch: %s", diff)
}
if runner.ranModel != "" {
t.Fatalf("expected configure-only flow to skip final launch, got %q", runner.ranModel)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
@@ -505,7 +627,9 @@ func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(
t.Fatalf("failed to save managed integration config: %v", err)
}
runner := &launcherManagedRunner{}
runner := &launcherManagedRunner{
currentModel: "gemma4",
}
withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
@@ -532,6 +656,53 @@ func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(
}
}
func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedMatchesButLiveConfigMissing(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withInteractiveSession(t, true)
withLauncherHooks(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/show":
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
t.Fatalf("failed to save managed integration config: %v", err)
}
runner := &launcherManagedRunner{}
withIntegrationOverride(t, "stubmanaged", runner)
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
t.Fatal("selector should not be called when saved model is usable")
return "", nil
}
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return true, nil
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
t.Fatalf("expected Configure to rewrite missing live config: %s", diff)
}
if runner.refreshCalls != 1 {
t.Fatalf("expected runtime refresh once after rewrite, got %d", runner.refreshCalls)
}
if runner.ranModel != "gemma4" {
t.Fatalf("expected launch to run saved model, got %q", runner.ranModel)
}
}
func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedDiffers(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
@@ -1292,6 +1463,53 @@ func TestBuildLauncherState_ToleratesInventoryFailure(t *testing.T) {
}
}
func TestBuildLauncherState_UsesTagsInventoryWithoutShow(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
if err := config.SetLastModel("llama3.2"); err != nil {
t.Fatalf("failed to seed last model: %v", err)
}
if err := config.SaveIntegration("codex", []string{"qwen3:8b"}); err != nil {
t.Fatalf("failed to seed codex config: %v", err)
}
var showCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/tags":
fmt.Fprint(w, `{"models":[`+
`{"name":"llama3.2","capabilities":["completion","tools"],"context_length":131072,"size":3200000000},`+
`{"name":"qwen3:8b","capabilities":["completion","tools"],"context_length":65536,"size":4500000000}`+
`]}`)
case "/api/show":
showCalls.Add(1)
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
state, err := BuildLauncherState(context.Background())
if err != nil {
t.Fatalf("BuildLauncherState returned error: %v", err)
}
if !state.RunModelUsable {
t.Fatal("expected saved run model to be usable from tags inventory")
}
if state.Integrations["codex"].CurrentModel != "qwen3:8b" {
t.Fatalf("expected codex current model from saved config, got %q", state.Integrations["codex"].CurrentModel)
}
if !state.Integrations["codex"].ModelUsable {
t.Fatal("expected saved codex model to be usable from tags inventory")
}
if got := showCalls.Load(); got != 0 {
t.Fatalf("show calls = %d, want 0 for broad launcher state", got)
}
}
func TestResolveRunModel_UsesSavedModelWithoutSelector(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
+201
View File
@@ -0,0 +1,201 @@
package launch
import (
"context"
"slices"
"strings"
"sync"
"github.com/ollama/ollama/api"
modelpkg "github.com/ollama/ollama/types/model"
)
// LaunchModel is the model metadata Launch passes to integration config
// writers after resolving selected model names through the per-run inventory.
type LaunchModel struct {
Name string
Remote bool
ToolCapable bool
Capabilities []modelpkg.Capability
ContextLength int
MaxOutputTokens int
EmbeddingLength int
Size int64
Details api.ModelDetails
}
type modelInfo = LaunchModel
// ModelInfo re-exports launcher model inventory details for callers.
type ModelInfo = LaunchModel
func (m LaunchModel) HasCapability(capability modelpkg.Capability) bool {
return slices.Contains(m.Capabilities, capability)
}
func (m LaunchModel) WithCloudLimits() LaunchModel {
if limit, ok := lookupCloudModelLimit(m.Name); ok {
if m.ContextLength <= 0 {
m.ContextLength = limit.Context
}
if m.MaxOutputTokens <= 0 {
m.MaxOutputTokens = limit.Output
}
}
return m
}
type modelInventory struct {
client *api.Client
mu sync.Mutex
loaded bool
models []LaunchModel
err error
}
func newModelInventory(client *api.Client) *modelInventory {
return &modelInventory{client: client}
}
func (i *modelInventory) Load(ctx context.Context) ([]LaunchModel, error) {
return i.load(ctx, false)
}
func (i *modelInventory) Refresh(ctx context.Context) ([]LaunchModel, error) {
return i.load(ctx, true)
}
func (i *modelInventory) load(ctx context.Context, force bool) ([]LaunchModel, error) {
if i == nil || i.client == nil {
return nil, nil
}
i.mu.Lock()
defer i.mu.Unlock()
if i.loaded && !force {
return cloneLaunchModels(i.models), i.err
}
resp, err := i.client.List(ctx)
if err != nil {
i.models = nil
i.err = err
i.loaded = true
return nil, err
}
i.models = make([]LaunchModel, 0, len(resp.Models))
for _, model := range resp.Models {
i.models = append(i.models, launchModelFromListResponse(model))
}
i.err = nil
i.loaded = true
return cloneLaunchModels(i.models), i.err
}
func (i *modelInventory) Resolve(ctx context.Context, names []string) []LaunchModel {
names = dedupeModelList(names)
if len(names) == 0 {
return nil
}
models, err := i.Load(ctx)
if err != nil {
models = nil
}
resolved, localMiss := resolveLaunchModels(names, models)
if localMiss {
if refreshed, err := i.Refresh(ctx); err == nil {
resolved, _ = resolveLaunchModels(names, refreshed)
}
}
return resolved
}
func resolveLaunchModels(names []string, models []LaunchModel) ([]LaunchModel, bool) {
resolved := make([]LaunchModel, 0, len(names))
localMiss := false
for _, name := range names {
if model, ok := findLaunchModel(models, name); ok {
resolved = append(resolved, model.WithCloudLimits())
continue
}
if !isCloudModelName(name) {
localMiss = true
}
resolved = append(resolved, fallbackLaunchModel(name))
}
return resolved, localMiss
}
func launchModelFromListResponse(model api.ListModelResponse) LaunchModel {
return LaunchModel{
Name: model.Name,
Remote: model.RemoteModel != "",
ToolCapable: slices.Contains(model.Capabilities, modelpkg.CapabilityTools),
Capabilities: append([]modelpkg.Capability(nil), model.Capabilities...),
ContextLength: model.Details.ContextLength,
EmbeddingLength: model.Details.EmbeddingLength,
Size: model.Size,
Details: model.Details,
}.WithCloudLimits()
}
func fallbackLaunchModel(name string) LaunchModel {
return LaunchModel{Name: name, Remote: isCloudModelName(name)}.WithCloudLimits()
}
func findLaunchModel(models []LaunchModel, name string) (LaunchModel, bool) {
for _, model := range models {
if launchModelMatches(model.Name, name) {
return cloneLaunchModel(model), true
}
}
return LaunchModel{}, false
}
func launchModelMatches(candidate, name string) bool {
if candidate == name {
return true
}
return strings.TrimSuffix(candidate, ":latest") == name
}
func cloneLaunchModel(model LaunchModel) LaunchModel {
model.Capabilities = append([]modelpkg.Capability(nil), model.Capabilities...)
model.Details.Families = append([]string(nil), model.Details.Families...)
return model
}
func cloneLaunchModels(models []LaunchModel) []LaunchModel {
cloned := make([]LaunchModel, len(models))
for i, model := range models {
cloned[i] = cloneLaunchModel(model)
}
return cloned
}
func launchModelNames(models []LaunchModel) []string {
names := make([]string, 0, len(models))
for _, model := range models {
if model.Name != "" {
names = append(names, model.Name)
}
}
return names
}
func launchModelsFromNames(names []string) []LaunchModel {
models := make([]LaunchModel, 0, len(names))
for _, name := range names {
if name == "" {
continue
}
models = append(models, fallbackLaunchModel(name))
}
return models
}
+80
View File
@@ -0,0 +1,80 @@
package launch
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/ollama/ollama/api"
modelpkg "github.com/ollama/ollama/types/model"
)
func TestModelInventoryResolveRefreshesLocalMiss(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/tags" {
http.NotFound(w, r)
return
}
calls++
if calls == 1 {
fmt.Fprint(w, `{"models":[]}`)
return
}
fmt.Fprint(w, `{"models":[{"name":"new-model","size":123,"details":{"context_length":65536,"embedding_length":1024},"capabilities":["vision","tools"]}]}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
inventory := newModelInventory(api.NewClient(u, srv.Client()))
got := inventory.Resolve(context.Background(), []string{"new-model"})
if calls != 2 {
t.Fatalf("List calls = %d, want 2", calls)
}
if len(got) != 1 {
t.Fatalf("Resolve returned %d models, want 1", len(got))
}
if got[0].Name != "new-model" {
t.Fatalf("Name = %q, want new-model", got[0].Name)
}
if got[0].ContextLength != 65_536 || got[0].EmbeddingLength != 1_024 {
t.Fatalf("metadata = context %d embedding %d, want refreshed metadata", got[0].ContextLength, got[0].EmbeddingLength)
}
if !got[0].HasCapability(modelpkg.CapabilityVision) || !got[0].ToolCapable {
t.Fatalf("capabilities = %v toolCapable=%v, want refreshed capabilities", got[0].Capabilities, got[0].ToolCapable)
}
}
func TestModelInventoryResolveDoesNotRefreshCloudMiss(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/tags" {
http.NotFound(w, r)
return
}
calls++
fmt.Fprint(w, `{"models":[]}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
inventory := newModelInventory(api.NewClient(u, srv.Client()))
got := inventory.Resolve(context.Background(), []string{"glm-5.1:cloud"})
if calls != 1 {
t.Fatalf("List calls = %d, want 1", calls)
}
if len(got) != 1 {
t.Fatalf("Resolve returned %d models, want 1", len(got))
}
if got[0].Name != "glm-5.1:cloud" || !got[0].Remote {
t.Fatalf("resolved model = %#v, want cloud fallback", got[0])
}
if got[0].ContextLength <= 0 || got[0].MaxOutputTokens <= 0 {
t.Fatalf("cloud limits not applied: %#v", got[0])
}
}
+21 -24
View File
@@ -23,10 +23,10 @@ import (
)
var recommendedModels = []ModelItem{
{Name: "kimi-k2.6:cloud", Description: "State-of-the-art coding, long-horizon execution, and multimodal agent swarm capability", Recommended: true, ContextLength: 262_144, MaxOutputTokens: 262_144},
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true, ContextLength: 262_144, MaxOutputTokens: 32_768},
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true, ContextLength: 202_752, MaxOutputTokens: 131_072},
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true, ContextLength: 204_800, MaxOutputTokens: 128_000},
{Name: "kimi-k2.6:cloud", Description: "State-of-the-art coding, long-horizon execution, and multimodal agent swarm capability", Recommended: true, Details: api.ModelDetails{ContextLength: 262_144}, MaxOutputTokens: 262_144},
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true, Details: api.ModelDetails{ContextLength: 262_144}, MaxOutputTokens: 32_768},
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true, Details: api.ModelDetails{ContextLength: 202_752}, MaxOutputTokens: 131_072},
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true, Details: api.ModelDetails{ContextLength: 204_800}, MaxOutputTokens: 128_000},
{Name: "gemma4", Description: "Reasoning and code generation locally", Recommended: true, VRAMBytes: 12 * format.GigaByte},
{Name: "qwen3.5", Description: "Reasoning, coding, and visual understanding locally", Recommended: true, VRAMBytes: 14 * format.GigaByte},
}
@@ -115,7 +115,7 @@ func setDynamicCloudModelLimits(limits map[string]cloudModelLimit) {
func cloudModelLimitsFromRecommendations(recommendations []ModelItem) map[string]cloudModelLimit {
limits := make(map[string]cloudModelLimit, len(recommendations))
for _, rec := range recommendations {
if !isCloudModelName(rec.Name) || rec.ContextLength <= 0 || rec.MaxOutputTokens <= 0 {
if !isCloudModelName(rec.Name) || rec.Details.ContextLength <= 0 || rec.MaxOutputTokens <= 0 {
continue
}
base, stripped := modelref.StripCloudSourceTag(rec.Name)
@@ -123,7 +123,7 @@ func cloudModelLimitsFromRecommendations(recommendations []ModelItem) map[string
continue
}
limits[base] = cloudModelLimit{
Context: rec.ContextLength,
Context: rec.Details.ContextLength,
Output: rec.MaxOutputTokens,
}
}
@@ -299,18 +299,17 @@ func pullMissingModel(ctx context.Context, client *api.Client, model string) err
}
// prepareEditorIntegration persists models and applies editor-managed config files.
func prepareEditorIntegration(name string, editor Editor, models []string) error {
func prepareEditorIntegration(name string, editor Editor, models []LaunchModel) error {
if err := editor.Edit(models); err != nil {
return fmt.Errorf("setup failed: %w", err)
}
if err := config.SaveIntegration(name, models); err != nil {
if err := config.SaveIntegration(name, launchModelNames(models)); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
return nil
}
func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, model string, models []string) error {
models = dedupeModelList(append([]string{model}, models...))
func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, model string, models []LaunchModel) error {
var err error
if withModels, ok := managed.(ManagedModelListConfigurer); ok {
err = withModels.ConfigureWithModels(model, models)
@@ -365,11 +364,11 @@ func buildModelListWithRecommendations(existing []modelInfo, recommendations []M
}
displayName := strings.TrimSuffix(m.Name, ":latest")
existingModels[displayName] = true
item := ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]}
if rec, ok := recByName[displayName]; ok {
item = copyModelRecommendationFields(displayName, rec)
items = append(items, modelItemFromInventory(displayName, m, copyModelRecommendationFields(displayName, rec)))
} else {
items = append(items, modelItemFromInventory(displayName, m, ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]}))
}
items = append(items, item)
}
for _, rec := range recommendations {
@@ -483,22 +482,20 @@ func copyModelRecommendationFields(name string, rec ModelItem) ModelItem {
return rec
}
func modelItemFromInventory(name string, info modelInfo, item ModelItem) ModelItem {
item.Name = name
item.ToolCapable = info.ToolCapable
item.Capabilities = slices.Clone(info.Capabilities)
item.Size = info.Size
item.Details = info.Details
return item
}
// isCloudModelName reports whether the model name has an explicit cloud source.
func isCloudModelName(name string) bool {
return modelref.HasExplicitCloudSource(name)
}
// filterCloudModels drops remote-only models from the given inventory.
func filterCloudModels(existing []modelInfo) []modelInfo {
filtered := existing[:0]
for _, m := range existing {
if !m.Remote {
filtered = append(filtered, m)
}
}
return filtered
}
// filterCloudItems removes cloud models from selection items.
func filterCloudItems(items []ModelItem) []ModelItem {
filtered := items[:0]
+83
View File
@@ -0,0 +1,83 @@
package launch
import (
"testing"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/format"
modelpkg "github.com/ollama/ollama/types/model"
)
func TestBuildModelList_UsesInventoryMetadataForInstalledModels(t *testing.T) {
existing := []modelInfo{
{
Name: "custom-tools:latest",
ToolCapable: true,
Capabilities: []modelpkg.Capability{modelpkg.CapabilityCompletion, modelpkg.CapabilityTools, modelpkg.CapabilityThinking},
Size: 7500 * format.MegaByte,
Details: api.ModelDetails{
ParameterSize: "8B",
QuantizationLevel: "Q4_K_M",
ContextLength: 131_072,
EmbeddingLength: 4096,
},
},
}
items, _, _, _ := buildModelList(existing, nil, "")
var got ModelItem
for _, item := range items {
if item.Name == "custom-tools" {
got = item
break
}
}
if got.Name == "" {
t.Fatal("custom-tools not found in items")
}
if !got.ToolCapable {
t.Fatal("expected installed model to preserve tool capability from tags metadata")
}
if got.Details.ContextLength != 131_072 {
t.Fatalf("Details.ContextLength = %d, want 131072", got.Details.ContextLength)
}
if got.Size != 7500*format.MegaByte {
t.Fatalf("Size = %d, want %d", got.Size, 7500*format.MegaByte)
}
if got.Description != "" {
t.Fatalf("Description = %q, want empty for installed model without recommendation copy", got.Description)
}
}
func TestBuildModelList_InstalledRecommendedPreservesRecommendationAndMetadata(t *testing.T) {
existing := []modelInfo{
{
Name: "qwen3.5",
ToolCapable: true,
Capabilities: []modelpkg.Capability{modelpkg.CapabilityCompletion, modelpkg.CapabilityTools, modelpkg.CapabilityVision},
Size: 14 * format.GigaByte,
Details: api.ModelDetails{ContextLength: 262_144},
},
}
items, _, _, _ := buildModelList(existing, nil, "")
var got ModelItem
for _, item := range items {
if item.Name == "qwen3.5" {
got = item
break
}
}
if got.Name == "" {
t.Fatal("qwen3.5 not found in items")
}
if !got.Recommended || !got.ToolCapable {
t.Fatalf("recommended/tool metadata = %v/%v, want true/true", got.Recommended, got.ToolCapable)
}
if got.Details.ContextLength != 262_144 {
t.Fatalf("Details.ContextLength = %d, want 262144", got.Details.ContextLength)
}
if got.Description != "Reasoning, coding, and visual understanding locally" {
t.Fatalf("Description = %q, want recommendation description", got.Description)
}
}
+17 -55
View File
@@ -1,7 +1,6 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"net"
@@ -10,21 +9,15 @@ import (
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/types/model"
)
const defaultGatewayPort = 18789
// Bound model capability probing so launch/config cannot hang on slow/unreachable API calls.
var openclawModelShowTimeout = 5 * time.Second
// openclawFreshInstall is set to true when ensureOpenclawInstalled performs an install
var openclawFreshInstall bool
@@ -34,7 +27,7 @@ type Openclaw struct{}
func (c *Openclaw) String() string { return "OpenClaw" }
func (c *Openclaw) Run(model string, args []string) error {
func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error {
bin, err := ensureOpenclawInstalled()
if err != nil {
return err
@@ -656,7 +649,7 @@ func (c *Openclaw) Paths() []string {
return nil
}
func (c *Openclaw) Edit(models []string) error {
func (c *Openclaw) Edit(models []LaunchModel) error {
if len(models) == 0 {
return nil
}
@@ -710,13 +703,11 @@ func (c *Openclaw) Edit(models []string) error {
}
}
client, _ := api.ClientFromEnvironment()
var newModels []any
for _, m := range models {
entry, _ := openclawModelConfig(context.Background(), client, m)
entry, _ := openclawModelConfig(m)
// Merge existing fields (user customizations)
if existing, ok := existingByID[m]; ok {
if existing, ok := existingByID[m.Name]; ok {
for k, v := range existing {
if _, isNew := entry[k]; !isNew {
entry[k] = v
@@ -744,7 +735,7 @@ func (c *Openclaw) Edit(models []string) error {
if modelConfig == nil {
modelConfig = make(map[string]any)
}
modelConfig["primary"] = "ollama/" + models[0]
modelConfig["primary"] = "ollama/" + models[0].Name
defaults["model"] = modelConfig
agents["defaults"] = defaults
config["agents"] = agents
@@ -759,7 +750,7 @@ func (c *Openclaw) Edit(models []string) error {
// Clear any per-session model overrides so the new primary takes effect
// immediately rather than being shadowed by a cached modelOverride.
clearSessionModelOverride(models[0])
clearSessionModelOverride(models[0].Name)
return nil
}
@@ -936,10 +927,10 @@ func configureOllamaWebSearch() {
// openclawModelConfig builds an OpenClaw model config entry with capability detection.
// The second return value indicates whether the model is a cloud (remote) model.
func openclawModelConfig(ctx context.Context, client *api.Client, modelID string) (map[string]any, bool) {
func openclawModelConfig(model LaunchModel) (map[string]any, bool) {
entry := map[string]any{
"id": modelID,
"name": modelID,
"id": model.Name,
"name": model.Name,
"input": []any{"text"},
"cost": map[string]any{
"input": 0,
@@ -949,53 +940,24 @@ func openclawModelConfig(ctx context.Context, client *api.Client, modelID string
},
}
if client == nil {
return entry, false
}
showCtx := ctx
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
var cancel context.CancelFunc
showCtx, cancel = context.WithTimeout(ctx, openclawModelShowTimeout)
defer cancel()
}
resp, err := client.Show(showCtx, &api.ShowRequest{Model: modelID})
if err != nil {
return entry, false
}
// Set input types based on vision capability
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
if model.HasCapability("vision") {
entry["input"] = []any{"text", "image"}
}
// Set reasoning based on thinking capability
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
if model.HasCapability("thinking") {
entry["reasoning"] = true
}
// Cloud models: use hardcoded limits for context/output tokens.
// Capability detection above still applies (vision, thinking).
if resp.RemoteModel != "" {
if l, ok := lookupCloudModelLimit(modelID); ok {
entry["contextWindow"] = l.Context
entry["maxTokens"] = l.Output
}
return entry, true
if model.ContextLength > 0 {
entry["contextWindow"] = model.ContextLength
}
if model.MaxOutputTokens > 0 {
entry["maxTokens"] = model.MaxOutputTokens
}
// Extract context window from ModelInfo (local models only)
for key, val := range resp.ModelInfo {
if strings.HasSuffix(key, ".context_length") {
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
entry["contextWindow"] = int(ctxLen)
}
break
}
}
return entry, false
return entry, model.Remote || isCloudModelName(model.Name)
}
func (c *Openclaw) Models() []string {
+62 -211
View File
@@ -2,12 +2,9 @@ package launch
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
@@ -16,8 +13,8 @@ import (
"testing"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/types/model"
)
func TestOpenclawIntegration(t *testing.T) {
@@ -78,7 +75,7 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
if err := c.Run("llama3.2", nil, []string{"gateway", "--someflag"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -152,7 +149,7 @@ fi
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", nil); err != nil {
if err := c.Run("llama3.2", nil, nil); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -224,7 +221,7 @@ func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) {
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", nil); err != nil {
if err := c.Run("llama3.2", nil, nil); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -287,7 +284,7 @@ exit 0
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", []string{"status"}); err != nil {
if err := c.Run("llama3.2", nil, []string{"status"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -367,7 +364,7 @@ exit 0
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
c := &Openclaw{}
if err := c.Run("llama3.2", []string{"tui"}); err != nil {
if err := c.Run("llama3.2", nil, []string{"tui"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -617,7 +614,7 @@ func TestOpenclawEdit(t *testing.T) {
t.Run("fresh install", func(t *testing.T) {
cleanup()
if err := c.Edit([]string{"llama3.2"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "llama3.2")
@@ -626,7 +623,7 @@ func TestOpenclawEdit(t *testing.T) {
t.Run("multiple models - first is primary", func(t *testing.T) {
cleanup()
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "llama3.2")
@@ -638,7 +635,7 @@ func TestOpenclawEdit(t *testing.T) {
cleanup()
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"models":{"providers":{"anthropic":{"apiKey":"xxx"}}}}`), 0o644)
if err := c.Edit([]string{"llama3.2"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
@@ -655,7 +652,7 @@ func TestOpenclawEdit(t *testing.T) {
cleanup()
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"theme":"dark","mcp":{"servers":{}}}`), 0o644)
if err := c.Edit([]string{"llama3.2"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
@@ -671,7 +668,7 @@ func TestOpenclawEdit(t *testing.T) {
t.Run("preserve user customizations on models", func(t *testing.T) {
cleanup()
c.Edit([]string{"llama3.2"})
c.Edit(testLaunchModels("llama3.2"))
// User adds custom field
data, _ := os.ReadFile(configPath)
@@ -687,7 +684,7 @@ func TestOpenclawEdit(t *testing.T) {
os.WriteFile(configPath, configData, 0o644)
// Re-run Edit
c.Edit([]string{"llama3.2"})
c.Edit(testLaunchModels("llama3.2"))
data, _ = os.ReadFile(configPath)
json.Unmarshal(data, &cfg)
@@ -703,8 +700,8 @@ func TestOpenclawEdit(t *testing.T) {
t.Run("edit replaces models list", func(t *testing.T) {
cleanup()
c.Edit([]string{"llama3.2", "mistral"})
c.Edit([]string{"llama3.2"})
c.Edit(testLaunchModels("llama3.2", "mistral"))
c.Edit(testLaunchModels("llama3.2"))
assertOpenclawModelExists(t, configPath, "llama3.2")
assertOpenclawModelNotExists(t, configPath, "mistral")
@@ -716,7 +713,7 @@ func TestOpenclawEdit(t *testing.T) {
original := `{"existing":"data"}`
os.WriteFile(configPath, []byte(original), 0o644)
c.Edit([]string{})
c.Edit(testLaunchModels())
data, _ := os.ReadFile(configPath)
if string(data) != original {
@@ -729,7 +726,7 @@ func TestOpenclawEdit(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{corrupted`), 0o644)
if err := c.Edit([]string{"llama3.2"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
@@ -745,7 +742,7 @@ func TestOpenclawEdit(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"models":"not a map"}`), 0o644)
if err := c.Edit([]string{"llama3.2"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "llama3.2")
@@ -925,7 +922,7 @@ func TestOpenclawEditSchemaFields(t *testing.T) {
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".openclaw", "openclaw.json")
if err := c.Edit([]string{"llama3.2"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
@@ -966,7 +963,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
t.Run("model with colon tag", func(t *testing.T) {
cleanup()
if err := c.Edit([]string{"llama3.2:70b"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2:70b")); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "llama3.2:70b")
@@ -975,7 +972,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
t.Run("model with slash", func(t *testing.T) {
cleanup()
if err := c.Edit([]string{"library/model:tag"}); err != nil {
if err := c.Edit(testLaunchModels("library/model:tag")); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "library/model:tag")
@@ -984,7 +981,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
t.Run("model with hyphen", func(t *testing.T) {
cleanup()
if err := c.Edit([]string{"test-model"}); err != nil {
if err := c.Edit(testLaunchModels("test-model")); err != nil {
t.Fatal(err)
}
assertOpenclawModelExists(t, configPath, "test-model")
@@ -1004,7 +1001,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{"model":{"primary":"old"},"temperature":0.7}}}`), 0o644)
c.Edit([]string{"llama3.2"})
c.Edit(testLaunchModels("llama3.2"))
data, _ := os.ReadFile(configPath)
var cfg map[string]any
@@ -1021,7 +1018,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{},"custom-agent":{"foo":"bar"}}}`), 0o644)
c.Edit([]string{"llama3.2"})
c.Edit(testLaunchModels("llama3.2"))
data, _ := os.ReadFile(configPath)
var cfg map[string]any
@@ -1061,7 +1058,7 @@ func TestOpenclawEdit_RoundTrip(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644)
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil {
t.Fatal(err)
}
@@ -1107,10 +1104,10 @@ func TestOpenclawEdit_Idempotent(t *testing.T) {
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644)
c.Edit([]string{"llama3.2", "mistral"})
c.Edit(testLaunchModels("llama3.2", "mistral"))
firstData, _ := os.ReadFile(configPath)
c.Edit([]string{"llama3.2", "mistral"})
c.Edit(testLaunchModels("llama3.2", "mistral"))
secondData, _ := os.ReadFile(configPath)
if string(firstData) != string(secondData) {
@@ -1133,7 +1130,7 @@ func TestOpenclawEdit_MultipleConsecutiveEdits(t *testing.T) {
if i%2 == 0 {
models = []string{"model-x", "model-y", "model-z"}
}
if err := c.Edit(models); err != nil {
if err := c.Edit(launchModelsFromNames(models)); err != nil {
t.Fatalf("edit %d failed: %v", i, err)
}
}
@@ -1162,7 +1159,7 @@ func TestOpenclawEdit_BackupCreated(t *testing.T) {
original := fmt.Sprintf(`{"theme": "%s"}`, uniqueMarker)
os.WriteFile(configPath, []byte(original), 0o644)
if err := c.Edit([]string{"model-a"}); err != nil {
if err := c.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -1284,7 +1281,7 @@ func TestOpenclawLegacyPaths(t *testing.T) {
os.WriteFile(filepath.Join(newDir, "openclaw.json"), []byte(`{"theme":"new"}`), 0o644)
os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"legacy"}`), 0o644)
if err := c.Edit([]string{"llama3.2"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
@@ -1303,7 +1300,7 @@ func TestOpenclawLegacyPaths(t *testing.T) {
os.MkdirAll(legacyDir, 0o755)
os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"dark"}`), 0o644)
if err := c.Edit([]string{"llama3.2"}); err != nil {
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
@@ -1331,7 +1328,7 @@ func TestOpenclawEdit_CreatesDirectoryIfMissing(t *testing.T) {
t.Fatal("directory should not exist before test")
}
if err := c.Edit([]string{"model-a"}); err != nil {
if err := c.Edit(testLaunchModels("model-a")); err != nil {
t.Fatal(err)
}
@@ -2248,8 +2245,8 @@ func TestPrintOpenclawReady(t *testing.T) {
}
func TestOpenclawModelConfig(t *testing.T) {
t.Run("nil client returns base config", func(t *testing.T) {
cfg, _ := openclawModelConfig(context.Background(), nil, "llama3.2")
t.Run("minimal model returns base config", func(t *testing.T) {
cfg, _ := openclawModelConfig(fallbackLaunchModel("llama3.2"))
if cfg["id"] != "llama3.2" {
t.Errorf("id = %v, want llama3.2", cfg["id"])
@@ -2260,29 +2257,17 @@ func TestOpenclawModelConfig(t *testing.T) {
if cfg["cost"] == nil {
t.Error("cost should be set")
}
// Should not have capability fields without API
// Should not have capability fields without inventory metadata.
if _, ok := cfg["reasoning"]; ok {
t.Error("reasoning should not be set without API")
t.Error("reasoning should not be set without metadata")
}
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set without API")
t.Error("contextWindow should not be set without metadata")
}
})
t.Run("sets vision input when model has vision capability", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{"llama.context_length":4096}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "llava:7b")
cfg, _ := openclawModelConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{"vision"}, ContextLength: 4096})
input, ok := cfg["input"].([]any)
if !ok || len(input) != 2 {
@@ -2291,19 +2276,7 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("sets text-only input when model lacks vision", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{"completion"}})
input, ok := cfg["input"].([]any)
if !ok || len(input) != 1 {
@@ -2315,39 +2288,15 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("sets reasoning when model has thinking capability", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "qwq")
cfg, _ := openclawModelConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{"thinking"}})
if cfg["reasoning"] != true {
t.Error("expected reasoning = true for thinking model")
}
})
t.Run("extracts context window from model info", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
t.Run("sets context window from inventory metadata", func(t *testing.T) {
cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072})
if cfg["contextWindow"] != 131072 {
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
@@ -2355,19 +2304,11 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("handles all capabilities together", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "qwen3-vision")
cfg, _ := openclawModelConfig(LaunchModel{
Name: "qwen3-vision",
Capabilities: []model.Capability{"vision", "thinking"},
ContextLength: 32768,
})
input, ok := cfg["input"].([]any)
if !ok || len(input) != 2 {
@@ -2381,17 +2322,8 @@ func TestOpenclawModelConfig(t *testing.T) {
}
})
t.Run("returns base config when show fails", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"model not found"}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "missing-model")
t.Run("returns base config when metadata is unavailable", func(t *testing.T) {
cfg, _ := openclawModelConfig(fallbackLaunchModel("missing-model"))
if cfg["id"] != "missing-model" {
t.Errorf("id = %v, want missing-model", cfg["id"])
@@ -2401,62 +2333,15 @@ func TestOpenclawModelConfig(t *testing.T) {
t.Error("input should always be set")
}
if _, ok := cfg["reasoning"]; ok {
t.Error("reasoning should not be set when show fails")
t.Error("reasoning should not be set when metadata is unavailable")
}
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set when show fails")
}
})
t.Run("times out slow show and returns base config", func(t *testing.T) {
oldTimeout := openclawModelShowTimeout
openclawModelShowTimeout = 50 * time.Millisecond
t.Cleanup(func() { openclawModelShowTimeout = oldTimeout })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
time.Sleep(300 * time.Millisecond)
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{"llama.context_length":4096}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
start := time.Now()
cfg, _ := openclawModelConfig(context.Background(), client, "slow-model")
elapsed := time.Since(start)
if elapsed >= 250*time.Millisecond {
t.Fatalf("openclawModelConfig took too long: %v", elapsed)
}
if cfg["id"] != "slow-model" {
t.Errorf("id = %v, want slow-model", cfg["id"])
}
if _, ok := cfg["reasoning"]; ok {
t.Error("reasoning should not be set on timeout")
}
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set on timeout")
t.Error("contextWindow should not be set when metadata is unavailable")
}
})
t.Run("skips zero context length", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, _ := openclawModelConfig(context.Background(), client, "test-model")
cfg, _ := openclawModelConfig(LaunchModel{Name: "test-model", ContextLength: 0})
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set for zero value")
@@ -2464,21 +2349,7 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("cloud model uses hardcoded limits", func(t *testing.T) {
// Use a model name that's in cloudModelLimits and make the server
// report it as a remote/cloud model
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{},"remote_model":"minimax-m2.7"}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, isCloud := openclawModelConfig(context.Background(), client, "minimax-m2.7:cloud")
cfg, isCloud := openclawModelConfig(fallbackLaunchModel("minimax-m2.7:cloud"))
if !isCloud {
t.Error("expected isCloud = true for cloud model")
@@ -2492,21 +2363,11 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("cloud model with vision capability gets image input", func(t *testing.T) {
// Regression test: cloud models must not skip capability detection.
// A cloud model that reports vision capability should have input: [text, image].
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{},"remote_model":"qwen3-vl"}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwen3-vl:235b-cloud")
cfg, isCloud := openclawModelConfig(LaunchModel{
Name: "qwen3-vl:235b-cloud",
Remote: true,
Capabilities: []model.Capability{"vision"},
}.WithCloudLimits())
if !isCloud {
t.Error("expected isCloud = true for cloud vision model")
@@ -2518,21 +2379,11 @@ func TestOpenclawModelConfig(t *testing.T) {
})
t.Run("cloud model with thinking capability gets reasoning flag", func(t *testing.T) {
// Regression test: cloud models must not skip capability detection.
// A cloud model that reports thinking capability should have reasoning: true.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{},"remote_model":"qwq-cloud"}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwq:cloud")
cfg, isCloud := openclawModelConfig(LaunchModel{
Name: "qwq:cloud",
Remote: true,
Capabilities: []model.Capability{"thinking"},
})
if !isCloud {
t.Error("expected isCloud = true for cloud thinking model")
+67 -21
View File
@@ -43,7 +43,7 @@ func findOpenCode() (string, bool) {
return "", false
}
func (o *OpenCode) Run(model string, args []string) error {
func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error {
opencodePath, ok := findOpenCode()
if !ok {
return fmt.Errorf("opencode is not installed, install from https://opencode.ai")
@@ -54,7 +54,7 @@ func (o *OpenCode) Run(model string, args []string) error {
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
if content := o.resolveContent(model); content != "" {
if content := o.resolveContent(model, models); content != "" {
cmd.Env = append(cmd.Env, "OPENCODE_CONFIG_CONTENT="+content)
}
return cmd.Run()
@@ -63,21 +63,57 @@ func (o *OpenCode) Run(model string, args []string) error {
// resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT.
// Returns content built by Edit if available, otherwise builds from model.json
// with the requested model as primary (e.g. re-launch with saved config).
func (o *OpenCode) resolveContent(model string) string {
func (o *OpenCode) resolveContent(model string, models []LaunchModel) string {
if o.configContent != "" {
return o.configContent
}
models := readModelJSONModels()
if !slices.Contains(models, model) {
models = append([]string{model}, models...)
resolvedModels := resolveOpenCodeRunModels(model, models, readModelJSONModels())
if len(resolvedModels) == 0 {
return ""
}
content, err := buildInlineConfig(model, models)
content, err := buildInlineConfig(resolvedModels[0], resolvedModels)
if err != nil {
return ""
}
return content
}
func resolveOpenCodeRunModels(primary string, models []LaunchModel, stateModels []string) []LaunchModel {
if primary == "" {
return nil
}
resolved := make([]LaunchModel, 0, 1+len(models)+len(stateModels))
appendModel := func(name string) {
if name == "" || hasLaunchModel(resolved, name) {
return
}
if model, ok := findLaunchModel(models, name); ok {
resolved = append(resolved, model)
return
}
resolved = append(resolved, fallbackLaunchModel(name))
}
appendModel(primary)
for _, model := range models {
appendModel(model.Name)
}
for _, model := range stateModels {
appendModel(model)
}
return resolved
}
func hasLaunchModel(models []LaunchModel, name string) bool {
for _, model := range models {
if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) {
return true
}
}
return false
}
func (o *OpenCode) Paths() []string {
sp, err := openCodeStatePath()
if err != nil {
@@ -100,12 +136,13 @@ func openCodeStatePath() (string, error) {
return filepath.Join(home, ".local", "state", "opencode", "model.json"), nil
}
func (o *OpenCode) Edit(modelList []string) error {
func (o *OpenCode) Edit(models []LaunchModel) error {
modelList := launchModelNames(models)
if len(modelList) == 0 {
return nil
}
content, err := buildInlineConfig(modelList[0], modelList)
content, err := buildInlineConfig(models[0], models)
if err != nil {
return err
}
@@ -172,10 +209,11 @@ func (o *OpenCode) Models() []string {
// buildInlineConfig produces the JSON string for OPENCODE_CONFIG_CONTENT.
// primary is the model to launch with, models is the full list of available models.
func buildInlineConfig(primary string, models []string) (string, error) {
if primary == "" || len(models) == 0 {
func buildInlineConfig(primary LaunchModel, models []LaunchModel) (string, error) {
if primary.Name == "" || len(models) == 0 {
return "", fmt.Errorf("buildInlineConfig: primary and models are required")
}
config := map[string]any{
"$schema": "https://opencode.ai/config.json",
"provider": map[string]any{
@@ -188,7 +226,7 @@ func buildInlineConfig(primary string, models []string) (string, error) {
"models": buildModelEntries(models),
},
},
"model": "ollama/" + primary,
"model": "ollama/" + primary.Name,
}
data, err := json.Marshal(config)
if err != nil {
@@ -228,21 +266,29 @@ func readModelJSONModels() []string {
return models
}
func buildModelEntries(modelList []string) map[string]any {
func buildModelEntries(modelList []LaunchModel) map[string]any {
models := make(map[string]any)
for _, model := range modelList {
entry := map[string]any{
"name": model,
"name": model.Name,
}
if isCloudModelName(model) {
if l, ok := lookupCloudModelLimit(model); ok {
entry["limit"] = map[string]any{
"context": l.Context,
"output": l.Output,
}
if model.HasCapability("vision") {
entry["modalities"] = map[string]any{
"input": []string{"text", "image"},
"output": []string{"text"},
}
}
models[model] = entry
if model.ContextLength > 0 || model.MaxOutputTokens > 0 {
limit := make(map[string]any)
if model.ContextLength > 0 {
limit["context"] = model.ContextLength
}
if model.MaxOutputTokens > 0 {
limit["output"] = model.MaxOutputTokens
}
entry["limit"] = limit
}
models[model.Name] = entry
}
return models
}
+108 -24
View File
@@ -7,6 +7,8 @@ import (
"path/filepath"
"runtime"
"testing"
"github.com/ollama/ollama/types/model"
)
func TestOpenCodeIntegration(t *testing.T) {
@@ -31,7 +33,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("builds config content with provider", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
if err := o.Edit([]string{"llama3.2"}); err != nil {
if err := o.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
@@ -65,7 +67,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("multiple models", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
if err := o.Edit([]string{"llama3.2", "qwen3:32b"}); err != nil {
if err := o.Edit(testLaunchModels("llama3.2", "qwen3:32b")); err != nil {
t.Fatal(err)
}
@@ -90,7 +92,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("empty models is no-op", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
if err := o.Edit([]string{}); err != nil {
if err := o.Edit(testLaunchModels()); err != nil {
t.Fatal(err)
}
if o.configContent != "" {
@@ -102,7 +104,7 @@ func TestOpenCodeEdit(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
o := &OpenCode{}
o.Edit([]string{"llama3.2"})
o.Edit(testLaunchModels("llama3.2"))
configDir := filepath.Join(tmpDir, ".config", "opencode")
@@ -117,7 +119,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("cloud model has limits", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil {
if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil {
t.Fatal(err)
}
@@ -144,7 +146,7 @@ func TestOpenCodeEdit(t *testing.T) {
t.Run("local model has no limits", func(t *testing.T) {
setTestHome(t, t.TempDir())
o := &OpenCode{}
o.Edit([]string{"llama3.2"})
o.Edit(testLaunchModels("llama3.2"))
var cfg map[string]any
json.Unmarshal([]byte(o.configContent), &cfg)
@@ -157,6 +159,43 @@ func TestOpenCodeEdit(t *testing.T) {
t.Errorf("local model should not have limit, got %v", entry["limit"])
}
})
t.Run("vision model gets image input modalities", func(t *testing.T) {
models := buildModelEntries([]LaunchModel{{Name: "gemma4:26b", Capabilities: []model.Capability{"vision"}}})
entry, _ := models["gemma4:26b"].(map[string]any)
modalities, _ := entry["modalities"].(map[string]any)
input, _ := modalities["input"].([]string)
output, _ := modalities["output"].([]string)
if len(input) != 2 || input[0] != "text" || input[1] != "image" {
t.Fatalf("modalities.input = %v, want [text image]", input)
}
if len(output) != 1 || output[0] != "text" {
t.Fatalf("modalities.output = %v, want [text]", output)
}
})
}
func TestBuildModelEntries(t *testing.T) {
t.Run("defaults to model name without capabilities", func(t *testing.T) {
models := buildModelEntries(testLaunchModels("llama3.2"))
entry, _ := models["llama3.2"].(map[string]any)
if entry["name"] != "llama3.2" {
t.Fatalf("name = %v, want llama3.2", entry["name"])
}
if _, ok := entry["modalities"]; ok {
t.Fatalf("modalities should not be set without capabilities, got %v", entry["modalities"])
}
})
t.Run("uses context and output limits from metadata", func(t *testing.T) {
models := buildModelEntries([]LaunchModel{{Name: "glm-5:cloud", ContextLength: 202_752, MaxOutputTokens: 131_072}})
entry, _ := models["glm-5:cloud"].(map[string]any)
limit, _ := entry["limit"].(map[string]any)
if limit["context"] != 202_752 || limit["output"] != 131_072 {
t.Fatalf("limit = %v, want context/output", limit)
}
})
}
func TestOpenCodeModels_ReturnsNil(t *testing.T) {
@@ -284,7 +323,7 @@ func TestOpenCodeEdit_CloudModelLimitStructure(t *testing.T) {
expected := cloudModelLimits["glm-4.7"]
if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil {
if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil {
t.Fatal(err)
}
@@ -314,7 +353,7 @@ func TestOpenCodeEdit_SpecialCharsInModelName(t *testing.T) {
specialModel := `model-with-"quotes"`
err := o.Edit([]string{specialModel})
err := o.Edit(testLaunchModels(specialModel))
if err != nil {
t.Fatalf("Edit with special chars failed: %v", err)
}
@@ -407,7 +446,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
setTestHome(t, tmpDir)
o := &OpenCode{}
if err := o.Edit([]string{"gemma4"}); err != nil {
if err := o.Edit(testLaunchModels("gemma4")); err != nil {
t.Fatal(err)
}
editContent := o.configContent
@@ -422,7 +461,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
data, _ := json.MarshalIndent(state, "", " ")
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
got := o.resolveContent("gemma4")
got := o.resolveContent("gemma4", nil)
if got != editContent {
t.Errorf("resolveContent returned different content than Edit set\ngot: %s\nwant: %s", got, editContent)
}
@@ -444,7 +483,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
content := o.resolveContent("llama3.2")
content := o.resolveContent("llama3.2", nil)
if content == "" {
t.Fatal("resolveContent returned empty")
}
@@ -478,7 +517,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
content := o.resolveContent("qwen3:32b")
content := o.resolveContent("qwen3:32b", nil)
var cfg map[string]any
json.Unmarshal([]byte(content), &cfg)
@@ -502,7 +541,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
content := o.resolveContent("gemma4")
content := o.resolveContent("gemma4", nil)
var cfg map[string]any
json.Unmarshal([]byte(content), &cfg)
@@ -522,11 +561,56 @@ func TestOpenCodeResolveContent(t *testing.T) {
setTestHome(t, tmpDir)
o := &OpenCode{}
if got := o.resolveContent(""); got != "" {
if got := o.resolveContent("", nil); got != "" {
t.Errorf("resolveContent(\"\") = %q, want empty", got)
}
})
t.Run("uses run model metadata when Edit was not called", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
stateDir := filepath.Join(tmpDir, ".local", "state", "opencode")
os.MkdirAll(stateDir, 0o755)
state := map[string]any{
"recent": []any{
map[string]any{"providerID": "ollama", "modelID": "llama3.2"},
},
}
data, _ := json.MarshalIndent(state, "", " ")
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
content := o.resolveContent("gemma4", []LaunchModel{
{
Name: "gemma4",
Capabilities: []model.Capability{model.CapabilityVision},
ContextLength: 65_536,
MaxOutputTokens: 8_192,
},
})
if content == "" {
t.Fatal("resolveContent returned empty")
}
var cfg map[string]any
json.Unmarshal([]byte(content), &cfg)
provider, _ := cfg["provider"].(map[string]any)
ollama, _ := provider["ollama"].(map[string]any)
cfgModels, _ := ollama["models"].(map[string]any)
entry, _ := cfgModels["gemma4"].(map[string]any)
limit, _ := entry["limit"].(map[string]any)
if limit["context"] != float64(65_536) || limit["output"] != float64(8_192) {
t.Fatalf("limit = %v, want context/output from launch metadata", limit)
}
if _, ok := entry["modalities"].(map[string]any); !ok {
t.Fatalf("modalities should be set from launch metadata, got %v", entry["modalities"])
}
if cfgModels["llama3.2"] == nil {
t.Fatalf("state model missing from fallback config: %v", cfgModels)
}
})
t.Run("does not mutate configContent on fallback", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -542,7 +626,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
_ = o.resolveContent("llama3.2")
_ = o.resolveContent("llama3.2", nil)
if o.configContent != "" {
t.Errorf("resolveContent should not mutate configContent, got %q", o.configContent)
}
@@ -551,19 +635,19 @@ func TestOpenCodeResolveContent(t *testing.T) {
func TestBuildInlineConfig(t *testing.T) {
t.Run("returns error for empty primary", func(t *testing.T) {
if _, err := buildInlineConfig("", []string{"llama3.2"}); err == nil {
if _, err := buildInlineConfig(LaunchModel{}, testLaunchModels("llama3.2")); err == nil {
t.Error("expected error for empty primary")
}
})
t.Run("returns error for empty models", func(t *testing.T) {
if _, err := buildInlineConfig("llama3.2", nil); err == nil {
if _, err := buildInlineConfig(fallbackLaunchModel("llama3.2"), nil); err == nil {
t.Error("expected error for empty models")
}
})
t.Run("primary differs from first model in list", func(t *testing.T) {
content, err := buildInlineConfig("qwen3:32b", []string{"llama3.2", "qwen3:32b"})
content, err := buildInlineConfig(fallbackLaunchModel("qwen3:32b"), testLaunchModels("llama3.2", "qwen3:32b"))
if err != nil {
t.Fatal(err)
}
@@ -592,7 +676,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
if err := o.Edit([]string{"new-X"}); err != nil {
if err := o.Edit(testLaunchModels("new-X")); err != nil {
t.Fatal(err)
}
@@ -626,7 +710,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
if err := o.Edit([]string{"X", "Y", "Z"}); err != nil {
if err := o.Edit(testLaunchModels("X", "Y", "Z")); err != nil {
t.Fatal(err)
}
@@ -663,7 +747,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
if err := o.Edit([]string{"qwen3:32b"}); err != nil {
if err := o.Edit(testLaunchModels("qwen3:32b")); err != nil {
t.Fatal(err)
}
@@ -700,7 +784,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
o := &OpenCode{}
if err := o.Edit([]string{"llama3.2"}); err != nil {
if err := o.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
@@ -742,7 +826,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
// Add 5 new models — should cap at 10 total
o := &OpenCode{}
if err := o.Edit([]string{"new-0", "new-1", "new-2", "new-3", "new-4"}); err != nil {
if err := o.Edit(testLaunchModels("new-0", "new-1", "new-2", "new-3", "new-4")); err != nil {
t.Fatal(err)
}
@@ -763,7 +847,7 @@ func TestOpenCodeEdit_BaseURL(t *testing.T) {
setTestHome(t, tmpDir)
// Default OLLAMA_HOST
o.Edit([]string{"llama3.2"})
o.Edit(testLaunchModels("llama3.2"))
var cfg map[string]any
json.Unmarshal([]byte(o.configContent), &cfg)
+13 -44
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
@@ -14,7 +13,6 @@ import (
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/types/model"
)
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
@@ -28,7 +26,7 @@ const (
func (p *Pi) String() string { return "Pi" }
func (p *Pi) Run(model string, args []string) error {
func (p *Pi) Run(_ string, _ []LaunchModel, args []string) error {
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
if err := ensureNpmInstalled(); err != nil {
return err
@@ -183,7 +181,7 @@ func (p *Pi) Paths() []string {
return paths
}
func (p *Pi) Edit(models []string) error {
func (p *Pi) Edit(models []LaunchModel) error {
if len(models) == 0 {
return nil
}
@@ -225,7 +223,7 @@ func (p *Pi) Edit(models []string) error {
// Build set of selected models to track which need to be added
selectedSet := make(map[string]bool, len(models))
for _, m := range models {
selectedSet[m] = true
selectedSet[m.Name] = true
}
// Build new models list:
@@ -256,11 +254,9 @@ func (p *Pi) Edit(models []string) error {
}
// Add newly selected models that weren't already in the list
client := api.NewClient(envconfig.Host(), http.DefaultClient)
ctx := context.Background()
for _, model := range models {
if selectedSet[model] {
newModels = append(newModels, createConfig(ctx, client, model))
if selectedSet[model.Name] {
newModels = append(newModels, createConfig(model))
}
}
@@ -284,7 +280,7 @@ func (p *Pi) Edit(models []string) error {
}
settings["defaultProvider"] = "ollama"
settings["defaultModel"] = models[0]
settings["defaultModel"] = models[0].Name
settingsData, err := json.MarshalIndent(settings, "", " ")
if err != nil {
@@ -342,54 +338,27 @@ func hasContextWindow(cfg map[string]any) bool {
}
}
// createConfig builds Pi model config with capability detection
func createConfig(ctx context.Context, client *api.Client, modelID string) map[string]any {
// createConfig builds Pi model config with capability detection.
func createConfig(model LaunchModel) map[string]any {
cfg := map[string]any{
"id": modelID,
"id": model.Name,
"_launch": true,
}
if l, ok := lookupCloudModelLimit(modelID); ok {
cfg["contextWindow"] = l.Context
}
applyCloudContextFallback := func() {
if l, ok := lookupCloudModelLimit(modelID); ok {
cfg["contextWindow"] = l.Context
}
}
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelID})
if err != nil {
applyCloudContextFallback()
return cfg
}
// Set input types based on vision capability
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
if model.HasCapability("vision") {
cfg["input"] = []string{"text", "image"}
} else {
cfg["input"] = []string{"text"}
}
// Set reasoning based on thinking capability
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
if model.HasCapability("thinking") {
cfg["reasoning"] = true
}
// Extract context window from ModelInfo. For known cloud models, the
// pre-filled shared limit remains unless the server provides a positive value.
hasContextWindow := false
for key, val := range resp.ModelInfo {
if strings.HasSuffix(key, ".context_length") {
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
cfg["contextWindow"] = int(ctxLen)
hasContextWindow = true
}
break
}
}
if !hasContextWindow {
applyCloudContextFallback()
if model.ContextLength > 0 {
cfg["contextWindow"] = model.ContextLength
}
return cfg
+44 -153
View File
@@ -1,19 +1,16 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/types/model"
)
@@ -138,7 +135,7 @@ exit 0
})
p := &Pi{}
if err := p.Run("ignored", []string{"--version"}); err != nil {
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -181,7 +178,7 @@ exit 0
})
p := &Pi{}
err := p.Run("ignored", nil)
err := p.Run("ignored", nil, nil)
if err == nil || !strings.Contains(err.Error(), "pi installation cancelled") {
t.Fatalf("expected install cancellation error, got %v", err)
}
@@ -203,7 +200,7 @@ exit 0
})
p := &Pi{}
if err := p.Run("ignored", []string{"session"}); err != nil {
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -238,7 +235,7 @@ exit 0
seedNpmNoop(t, tmpDir)
p := &Pi{}
if err := p.Run("ignored", []string{"doctor"}); err != nil {
if err := p.Run("ignored", nil, []string{"doctor"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
@@ -266,7 +263,7 @@ exit 0
p := &Pi{}
stderr := captureStderr(t, func() {
if err := p.Run("ignored", []string{"session"}); err != nil {
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
t.Fatalf("Run() should continue after web search update failure, got %v", err)
}
})
@@ -301,7 +298,7 @@ exit 0
p := &Pi{}
stderr := captureStderr(t, func() {
if err := p.Run("ignored", []string{"session"}); err != nil {
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
t.Fatalf("Run() should continue after web search install failure, got %v", err)
}
})
@@ -331,7 +328,7 @@ exit 0
p := &Pi{}
stderr := captureStderr(t, func() {
if err := p.Run("ignored", []string{"session"}); err != nil {
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
})
@@ -360,7 +357,7 @@ exit 0
seedPiScript(t, tmpDir)
p := &Pi{}
err := p.Run("ignored", []string{"session"})
err := p.Run("ignored", nil, []string{"session"})
if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required to launch pi") {
t.Fatalf("expected missing npm error, got %v", err)
}
@@ -435,7 +432,7 @@ func TestPiEdit(t *testing.T) {
}
t.Run("returns nil for empty models", func(t *testing.T) {
if err := pi.Edit([]string{}); err != nil {
if err := pi.Edit(testLaunchModels()); err != nil {
t.Errorf("Edit([]) error = %v, want nil", err)
}
})
@@ -444,7 +441,7 @@ func TestPiEdit(t *testing.T) {
cleanup()
models := []string{"llama3.2", "qwen3:8b"}
if err := pi.Edit(models); err != nil {
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -497,7 +494,7 @@ func TestPiEdit(t *testing.T) {
}
models := []string{"new-model"}
if err := pi.Edit(models); err != nil {
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -550,7 +547,7 @@ func TestPiEdit(t *testing.T) {
t.Fatal(err)
}
if err := pi.Edit([]string{"glm-5:cloud"}); err != nil {
if err := pi.Edit(testLaunchModels("glm-5:cloud")); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -595,7 +592,7 @@ func TestPiEdit(t *testing.T) {
}
newModels := []string{"new-model-1", "new-model-2"}
if err := pi.Edit(newModels); err != nil {
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -646,7 +643,7 @@ func TestPiEdit(t *testing.T) {
}
newModels := []string{"keep-model", "add-model"}
if err := pi.Edit(newModels); err != nil {
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -683,7 +680,7 @@ func TestPiEdit(t *testing.T) {
}
models := []string{"test-model"}
if err := pi.Edit(models); err != nil {
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
t.Fatalf("Edit() should not fail with corrupt config, got %v", err)
}
@@ -732,7 +729,7 @@ func TestPiEdit(t *testing.T) {
// Add a new ollama-managed model
newModels := []string{"new-ollama-model"}
if err := pi.Edit(newModels); err != nil {
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -793,7 +790,7 @@ func TestPiEdit(t *testing.T) {
}
models := []string{"llama3.2"}
if err := pi.Edit(models); err != nil {
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -831,7 +828,7 @@ func TestPiEdit(t *testing.T) {
os.MkdirAll(configDir, 0o755)
models := []string{"qwen3:8b"}
if err := pi.Edit(models); err != nil {
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -865,7 +862,7 @@ func TestPiEdit(t *testing.T) {
}
models := []string{"test-model"}
if err := pi.Edit(models); err != nil {
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
t.Fatalf("Edit() should not fail with corrupt settings, got %v", err)
}
@@ -921,7 +918,7 @@ func TestPiEdit_CreatesDistinctBackupsForEachManagedFile(t *testing.T) {
t.Fatal(err)
}
if err := pi.Edit([]string{"llama3.2"}); err != nil {
if err := pi.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatalf("Edit() error = %v", err)
}
@@ -1087,19 +1084,7 @@ func TestIsPiOllamaModel(t *testing.T) {
func TestCreateConfig(t *testing.T) {
t.Run("sets vision input when model has vision capability", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "llava:7b")
cfg := createConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{model.CapabilityVision}})
if cfg["id"] != "llava:7b" {
t.Errorf("id = %v, want llava:7b", cfg["id"])
@@ -1114,19 +1099,7 @@ func TestCreateConfig(t *testing.T) {
})
t.Run("sets text-only input when model lacks vision", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "llama3.2")
cfg := createConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{model.CapabilityCompletion}})
input, ok := cfg["input"].([]string)
if !ok || len(input) != 1 || input[0] != "text" {
@@ -1138,39 +1111,15 @@ func TestCreateConfig(t *testing.T) {
})
t.Run("sets reasoning when model has thinking capability", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "qwq")
cfg := createConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{model.CapabilityThinking}})
if cfg["reasoning"] != true {
t.Error("expected reasoning = true for thinking model")
}
})
t.Run("extracts context window from model info", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "llama3.2")
t.Run("sets context window from metadata", func(t *testing.T) {
cfg := createConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072})
if cfg["contextWindow"] != 131072 {
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
@@ -1178,19 +1127,11 @@ func TestCreateConfig(t *testing.T) {
})
t.Run("handles all capabilities together", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "qwen3-vision")
cfg := createConfig(LaunchModel{
Name: "qwen3-vision",
Capabilities: []model.Capability{model.CapabilityVision, model.CapabilityThinking},
ContextLength: 32768,
})
input := cfg["input"].([]string)
if len(input) != 2 || input[0] != "text" || input[1] != "image" {
@@ -1204,17 +1145,8 @@ func TestCreateConfig(t *testing.T) {
}
})
t.Run("returns minimal config when show fails", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"model not found"}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "missing-model")
t.Run("returns minimal config when metadata is unavailable", func(t *testing.T) {
cfg := createConfig(LaunchModel{Name: "missing-model"})
if cfg["id"] != "missing-model" {
t.Errorf("id = %v, want missing-model", cfg["id"])
@@ -1222,49 +1154,29 @@ func TestCreateConfig(t *testing.T) {
if cfg["_launch"] != true {
t.Error("expected _launch = true")
}
// Should not have capability fields
if _, ok := cfg["input"]; ok {
t.Error("input should not be set when show fails")
// Input defaults to text even when capabilities are unavailable.
input, ok := cfg["input"].([]string)
if !ok || len(input) != 1 || input[0] != "text" {
t.Errorf("input = %v, want [text]", cfg["input"])
}
if _, ok := cfg["reasoning"]; ok {
t.Error("reasoning should not be set when show fails")
t.Error("reasoning should not be set when metadata is unavailable")
}
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set when show fails")
t.Error("contextWindow should not be set when metadata is unavailable")
}
})
t.Run("cloud model falls back to hardcoded context when show fails", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"model not found"}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "kimi-k2.5:cloud")
t.Run("cloud model falls back to hardcoded context", func(t *testing.T) {
cfg := createConfig(fallbackLaunchModel("kimi-k2.5:cloud"))
if cfg["contextWindow"] != 262_144 {
t.Errorf("contextWindow = %v, want 262144", cfg["contextWindow"])
}
})
t.Run("cloud model falls back to hardcoded context when show omits model info", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "glm-5:cloud")
t.Run("cloud model uses hardcoded context when tags omit context", func(t *testing.T) {
cfg := createConfig(fallbackLaunchModel("glm-5:cloud"))
if cfg["contextWindow"] != 202_752 {
t.Errorf("contextWindow = %v, want 202752", cfg["contextWindow"])
@@ -1272,35 +1184,14 @@ func TestCreateConfig(t *testing.T) {
})
t.Run("cloud model with dash suffix falls back to hardcoded context", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, `{"error":"model not found"}`)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "gpt-oss:120b-cloud")
cfg := createConfig(fallbackLaunchModel("gpt-oss:120b-cloud"))
if cfg["contextWindow"] != 131_072 {
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
}
})
t.Run("skips zero context length", func(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
client := api.NewClient(u, srv.Client())
cfg := createConfig(context.Background(), client, "test-model")
cfg := createConfig(LaunchModel{Name: "test-model", ContextLength: 0})
if _, ok := cfg["contextWindow"]; ok {
t.Error("contextWindow should not be set for zero value")
+1 -1
View File
@@ -29,7 +29,7 @@ func (p *Poolside) args(model string, extra []string) []string {
return args
}
func (p *Poolside) Run(model string, args []string) error {
func (p *Poolside) Run(model string, _ []LaunchModel, args []string) error {
if poolsideGOOS == "windows" {
return poolsideUnsupportedError()
}
+2 -2
View File
@@ -51,7 +51,7 @@ func TestPoolsideRunSetsOllamaEnv(t *testing.T) {
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
p := &Poolside{}
if err := p.Run("qwen3.5", []string{"session"}); err != nil {
if err := p.Run("qwen3.5", nil, []string{"session"}); err != nil {
t.Fatalf("Run returned error: %v", err)
}
@@ -78,7 +78,7 @@ func TestPoolsideRunWindowsUnsupported(t *testing.T) {
t.Cleanup(func() { poolsideGOOS = prev })
p := &Poolside{}
err := p.Run("kimi-k2.6:cloud", nil)
err := p.Run("kimi-k2.6:cloud", nil, nil)
if err == nil {
t.Fatal("expected Windows unsupported error")
}
+13 -1
View File
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string
}
var launcherIntegrationOrder = []string{"claude", "openclaw", "hermes", "opencode", "codex", "copilot", "droid", "pi", "pool"}
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "droid", "pi", "pool"}
var integrationSpecs = []*IntegrationSpec{
{
@@ -87,6 +87,18 @@ var integrationSpecs = []*IntegrationSpec{
Command: []string{"npm", "install", "-g", "@openai/codex"},
},
},
{
Name: "codex-app",
Runner: &CodexApp{},
Aliases: []string{"codex-desktop", "codex-gui"},
Description: "An AI agent you can delegate real work to, by OpenAI",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
return codexAppInstalled()
},
URL: "https://developers.openai.com/codex/quickstart",
},
},
{
Name: "kimi",
Runner: &Kimi{},
+1 -1
View File
@@ -84,7 +84,7 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
t.Setenv("PATH", binDir)
configPath := tt.checkPath(home)
if err := tt.runner.Run("llama3.2", nil); err != nil {
if err := tt.runner.Run("llama3.2", nil, nil); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
+4
View File
@@ -41,6 +41,10 @@ func setTestHome(t *testing.T, dir string) {
setLaunchTestHome(t, dir)
}
func testLaunchModels(names ...string) []LaunchModel {
return launchModelsFromNames(names)
}
func SaveIntegration(appName string, models []string) error {
return config.SaveIntegration(appName, models)
}
+2 -2
View File
@@ -126,7 +126,7 @@ const (
minVSCodeVersion = "1.113"
)
func (v *VSCode) Run(model string, args []string) error {
func (v *VSCode) Run(model string, _ []LaunchModel, args []string) error {
v.checkVSCodeVersion()
v.checkCopilotChatVersion()
@@ -238,7 +238,7 @@ func (v *VSCode) Paths() []string {
return nil
}
func (v *VSCode) Edit(models []string) error {
func (v *VSCode) Edit(models []LaunchModel) error {
if len(models) == 0 {
return nil
}
+3 -3
View File
@@ -113,7 +113,7 @@ func TestVSCodeEdit(t *testing.T) {
os.WriteFile(clmPath, []byte(tt.setup), 0o644)
}
if err := v.Edit(tt.models); err != nil {
if err := v.Edit(launchModelsFromNames(tt.models)); err != nil {
t.Fatal(err)
}
@@ -134,7 +134,7 @@ func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
os.MkdirAll(filepath.Dir(settingsPath), 0o755)
os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644)
if err := v.Edit([]string{"llama3.2"}); err != nil {
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
@@ -180,7 +180,7 @@ func TestVSCodeEdit_CreatesDistinctBackupsForManagedFiles(t *testing.T) {
t.Fatal(err)
}
if err := v.Edit([]string{"llama3.2"}); err != nil {
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
t.Fatal(err)
}
+21 -1
View File
@@ -29,6 +29,13 @@ func launcherTestState() *launch.LauncherState {
Selectable: true,
Changeable: true,
},
"codex-app": {
Name: "codex-app",
DisplayName: "Codex App",
Description: "An AI agent you can delegate real work to, by OpenAI",
Selectable: true,
Changeable: true,
},
"openclaw": {
Name: "openclaw",
DisplayName: "OpenClaw",
@@ -122,12 +129,25 @@ func expectedExpandedSequence(state *launch.LauncherState) []string {
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
state := launcherTestState()
menu := newModel(state)
wantPrefix := []string{"run", "claude", "codex-app", "hermes", "openclaw"}
if findMenuCursorByIntegration(menu.items, "codex-app") == -1 {
wantPrefix = []string{"run", "claude", "hermes", "openclaw", "opencode"}
}
if got := integrationSequence(menu.items); len(got) < len(wantPrefix) {
t.Fatalf("expected at least %d menu items, got %v", len(wantPrefix), got)
} else if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" {
t.Fatalf("unexpected primary TUI order: %s", diff)
}
view := menu.View()
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch OpenClaw", "Launch Hermes Agent", "More..."} {
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Hermes Agent", "Launch OpenClaw", "More..."} {
if !strings.Contains(view, want) {
t.Fatalf("expected menu view to contain %q\n%s", want, view)
}
}
if findMenuCursorByIntegration(menu.items, "codex-app") != -1 && !strings.Contains(view, "Launch Codex App") {
t.Fatalf("expected menu view to contain Codex App\n%s", view)
}
if strings.Contains(view, "Launch Claude Desktop") {
t.Fatalf("expected hidden Claude Desktop to be absent\n%s", view)
}
+72 -3
View File
@@ -147,7 +147,9 @@ func (ModelParameters) KV(t *Tokenizer) KV {
}
for _, sv := range t.SpecialVocabulary {
kv[fmt.Sprintf("tokenizer.ggml.add_%s_token", sv.Key())] = sv.AddToken
if sv.AddTokenSet {
kv[fmt.Sprintf("tokenizer.ggml.add_%s_token", sv.Key())] = sv.AddToken
}
kv[fmt.Sprintf("tokenizer.ggml.%s_token_id", sv.Key())] = uint32(sv.ID)
if len(sv.IDs) > 0 {
kv[fmt.Sprintf("tokenizer.ggml.%s_token_ids", sv.Key())] = sv.IDs
@@ -200,10 +202,32 @@ type ModelConverter interface {
specialTokenTypes() []string
}
// MultimodalConverter splits checkpoints with embedded vision/projector
// weights into a text model GGUF and a separate projector GGUF.
type MultimodalConverter interface {
ModelConverter
TextKV(*Tokenizer) KV
TextTensors([]Tensor, *Tokenizer) []*ggml.Tensor
ProjectorKV(*Tokenizer) KV
ProjectorTensors([]Tensor) []*ggml.Tensor
}
type moreParser interface {
parseMore(fs.FS) error
}
type extraTensorParser interface {
extraTensors(fs.FS) ([]Tensor, error)
}
type tokenizerAdjuster interface {
adjustTokenizer(*Tokenizer)
}
type tokenizerAwareTensorConverter interface {
TensorsWithTokenizer([]Tensor, *Tokenizer) []*ggml.Tensor
}
type AdapterConverter interface {
// KV maps parameters to LLM key-values
KV(ofs.Config) KV
@@ -288,6 +312,8 @@ func LoadModelMetadata(fsys fs.FS) (ModelKV, *Tokenizer, error) {
conv = &gemma2Model{}
case "Gemma3ForCausalLM", "Gemma3ForConditionalGeneration":
conv = &gemma3Model{Architecture: p.Architectures[0]}
case "Gemma3TextModel":
conv = &embeddingGemmaModel{}
case "Gemma3nForConditionalGeneration":
conv = &gemma3nModel{}
case "Gemma4ForCausalLM", "Gemma4ForConditionalGeneration":
@@ -348,6 +374,9 @@ func LoadModelMetadata(fsys fs.FS) (ModelKV, *Tokenizer, error) {
if err != nil {
return nil, nil, err
}
if ta, ok := conv.(tokenizerAdjuster); ok {
ta.adjustTokenizer(t)
}
vocabSize := int(cmp.Or(p.VocabSize, p.TextModel.VocabSize))
@@ -375,7 +404,7 @@ func LoadModelMetadata(fsys fs.FS) (ModelKV, *Tokenizer, error) {
// and files it finds in the input path.
// Supported input model formats include safetensors.
// Supported input tokenizers files include tokenizer.json (preferred) and tokenizer.model.
func ConvertModel(fsys fs.FS, f *os.File) error {
func ConvertModel(fsys fs.FS, f *os.File, projectorFiles ...*os.File) error {
kv, t, err := LoadModelMetadata(fsys)
if err != nil {
return err
@@ -387,7 +416,47 @@ func ConvertModel(fsys fs.FS, f *os.File) error {
return err
}
return writeFile(f, conv.KV(t), conv.Tensors(ts))
if tp, ok := conv.(extraTensorParser); ok {
extra, err := tp.extraTensors(fsys)
if err != nil {
return err
}
ts = append(ts, extra...)
}
if err := ensureUniqueTensorNames(ts); err != nil {
return err
}
if mc, ok := conv.(MultimodalConverter); ok && len(projectorFiles) > 0 && projectorFiles[0] != nil {
projectorTensors := mc.ProjectorTensors(ts)
if len(projectorTensors) > 0 {
if err := writeFile(f, mc.TextKV(t), mc.TextTensors(ts, t)); err != nil {
return err
}
return writeFile(projectorFiles[0], mc.ProjectorKV(t), projectorTensors)
}
}
var tensors []*ggml.Tensor
if tc, ok := conv.(tokenizerAwareTensorConverter); ok {
tensors = tc.TensorsWithTokenizer(ts, t)
} else {
tensors = conv.Tensors(ts)
}
return writeFile(f, conv.KV(t), tensors)
}
func ensureUniqueTensorNames(ts []Tensor) error {
names := make(map[string]struct{}, len(ts))
for _, t := range ts {
if _, ok := names[t.Name()]; ok {
return fmt.Errorf("duplicate tensor name '%s' was found for this model", t.Name())
}
names[t.Name()] = struct{}{}
}
return nil
}
func writeFile(f *os.File, kv KV, ts []*ggml.Tensor) error {
+280
View File
@@ -0,0 +1,280 @@
package convert
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"io/fs"
"path"
"slices"
"strings"
"github.com/ollama/ollama/fs/ggml"
)
type embeddingGemmaModel struct {
gemmaModel
RopeLocalTheta float32 `json:"rope_local_base_freq"`
RopeTheta float32 `json:"rope_theta"`
SlidingWindow uint32 `json:"sliding_window"`
poolingType uint32
denseModules []embeddingGemmaDenseModule
}
type embeddingGemmaDenseModule struct {
path string
tensorName string
in, out uint32
}
var (
_ ModelConverter = (*embeddingGemmaModel)(nil)
_ moreParser = (*embeddingGemmaModel)(nil)
_ extraTensorParser = (*embeddingGemmaModel)(nil)
_ tokenizerAdjuster = (*embeddingGemmaModel)(nil)
)
func (m *embeddingGemmaModel) KV(t *Tokenizer) KV {
kv := m.ModelParameters.KV(t)
kv["general.architecture"] = "gemma-embedding"
kv["gemma-embedding.context_length"] = cmp.Or(m.MaxPositionEmbeddings, uint32(2048))
kv["gemma-embedding.embedding_length"] = m.HiddenSize
kv["gemma-embedding.block_count"] = m.HiddenLayers
kv["gemma-embedding.feed_forward_length"] = m.IntermediateSize
kv["gemma-embedding.attention.head_count"] = m.NumAttentionHeads
kv["gemma-embedding.attention.head_count_kv"] = m.NumKeyValueHeads
kv["gemma-embedding.attention.layer_norm_rms_epsilon"] = cmp.Or(m.RMSNormEPS, float32(1e-6))
kv["gemma-embedding.attention.key_length"] = m.HeadDim
kv["gemma-embedding.attention.value_length"] = m.HeadDim
kv["gemma-embedding.attention.sliding_window"] = m.SlidingWindow
kv["gemma-embedding.rope.freq_base"] = cmp.Or(m.RopeTheta, float32(1000000.0))
kv["gemma-embedding.rope.freq_base_swa"] = cmp.Or(m.RopeLocalTheta, float32(10000.0))
kv["gemma-embedding.pooling_type"] = cmp.Or(m.poolingType, uint32(1))
for _, dense := range m.denseModules {
kv["gemma-embedding."+dense.tensorName+"_feat_in"] = dense.in
kv["gemma-embedding."+dense.tensorName+"_feat_out"] = dense.out
}
return kv
}
func (m *embeddingGemmaModel) parseMore(fsys fs.FS) error {
bts, err := fs.ReadFile(fsys, "modules.json")
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return errors.New("embeddinggemma requires sentence-transformers modules.json")
}
return err
}
var modules []struct {
Type string `json:"type"`
Path string `json:"path"`
}
if err := json.Unmarshal(bts, &modules); err != nil {
return err
}
m.poolingType = 1
m.denseModules = nil
for _, module := range modules {
switch module.Type {
case "sentence_transformers.models.Pooling":
poolingType, err := embeddingGemmaPoolingType(fsys, module.Path)
if err != nil {
return err
}
if poolingType != 0 {
m.poolingType = poolingType
}
case "sentence_transformers.models.Dense":
dense, ok, err := embeddingGemmaDenseModuleConfig(fsys, module.Path)
if err != nil {
return err
}
if ok {
m.denseModules = append(m.denseModules, dense)
}
}
}
slices.SortFunc(m.denseModules, func(a, b embeddingGemmaDenseModule) int {
return strings.Compare(a.tensorName, b.tensorName)
})
if len(m.denseModules) != 2 ||
m.denseModules[0].tensorName != "dense_2" ||
m.denseModules[1].tensorName != "dense_3" {
return errors.New("embeddinggemma requires sentence-transformers 2_Dense and 3_Dense modules")
}
return nil
}
func (m *embeddingGemmaModel) adjustTokenizer(t *Tokenizer) {
n := int(m.VocabSize)
if n == 0 || len(t.Vocabulary.Tokens) <= n {
return
}
t.Vocabulary.Tokens = t.Vocabulary.Tokens[:n]
if len(t.Vocabulary.Scores) > n {
t.Vocabulary.Scores = t.Vocabulary.Scores[:n]
}
if len(t.Vocabulary.Types) > n {
t.Vocabulary.Types = t.Vocabulary.Types[:n]
}
}
func embeddingGemmaPoolingType(fsys fs.FS, modulePath string) (uint32, error) {
if modulePath == "" {
return 0, nil
}
bts, err := fs.ReadFile(fsys, path.Join(modulePath, "config.json"))
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return 0, nil
}
return 0, err
}
var cfg struct {
PoolingModeMeanTokens bool `json:"pooling_mode_mean_tokens"`
PoolingModeCLSToken bool `json:"pooling_mode_cls_token"`
}
if err := json.Unmarshal(bts, &cfg); err != nil {
return 0, err
}
switch {
case cfg.PoolingModeMeanTokens:
return 1, nil
case cfg.PoolingModeCLSToken:
return 2, nil
default:
return 0, nil
}
}
func embeddingGemmaDenseModuleConfig(fsys fs.FS, modulePath string) (embeddingGemmaDenseModule, bool, error) {
tensorName, ok := embeddingGemmaDenseTensorName(modulePath)
if !ok {
return embeddingGemmaDenseModule{}, false, nil
}
weightsPath := path.Join(modulePath, "model.safetensors")
if _, err := fs.Stat(fsys, weightsPath); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return embeddingGemmaDenseModule{}, false, nil
}
return embeddingGemmaDenseModule{}, false, err
}
bts, err := fs.ReadFile(fsys, path.Join(modulePath, "config.json"))
if err != nil {
return embeddingGemmaDenseModule{}, false, err
}
var cfg struct {
InFeatures uint32 `json:"in_features"`
OutFeatures uint32 `json:"out_features"`
Bias bool `json:"bias"`
}
if err := json.Unmarshal(bts, &cfg); err != nil {
return embeddingGemmaDenseModule{}, false, err
}
if cfg.InFeatures == 0 || cfg.OutFeatures == 0 {
return embeddingGemmaDenseModule{}, false, errors.New("embeddinggemma dense layer config missing in/out features")
}
if cfg.Bias {
return embeddingGemmaDenseModule{}, false, fmt.Errorf("embeddinggemma dense layer %s has unsupported bias", modulePath)
}
return embeddingGemmaDenseModule{
path: weightsPath,
tensorName: tensorName,
in: cfg.InFeatures,
out: cfg.OutFeatures,
}, true, nil
}
func embeddingGemmaDenseTensorName(modulePath string) (string, bool) {
switch modulePath {
case "2_Dense":
return "dense_2", true
case "3_Dense":
return "dense_3", true
default:
return "", false
}
}
func (m *embeddingGemmaModel) extraTensors(fsys fs.FS) ([]Tensor, error) {
var extra []Tensor
for _, dense := range m.denseModules {
ts, err := parseSafetensors(fsys, strings.NewReplacer("linear.", dense.tensorName+"."), dense.path)
if err != nil {
return nil, err
}
foundWeight := false
for _, t := range ts {
if t.Name() == dense.tensorName+".weight" {
extra = append(extra, t)
foundWeight = true
}
}
if !foundWeight {
return nil, fmt.Errorf("embeddinggemma dense module %s missing linear.weight", dense.path)
}
}
return extra, nil
}
func (m *embeddingGemmaModel) Tensors(ts []Tensor) []*ggml.Tensor {
out := make([]*ggml.Tensor, 0, len(ts))
for _, t := range ts {
name := t.Name()
if name == "norm.weight" {
name = "output_norm.weight"
}
if strings.HasSuffix(name, "_norm.weight") {
t.SetRepacker(m.addOne)
}
out = append(out, &ggml.Tensor{
Name: name,
Kind: t.Kind(),
Shape: t.Shape(),
WriterTo: t,
})
}
return out
}
func (m *embeddingGemmaModel) Replacements() []string {
return []string{
"embed_tokens.", "token_embd.",
"layers.", "blk.",
"input_layernorm", "attn_norm",
"self_attn.q_proj", "attn_q",
"self_attn.q_norm", "attn_q_norm",
"self_attn.k_proj", "attn_k",
"self_attn.k_norm", "attn_k_norm",
"self_attn.v_proj", "attn_v",
"self_attn.o_proj", "attn_output",
"mlp.gate_proj", "ffn_gate",
"mlp.down_proj", "ffn_down",
"mlp.up_proj", "ffn_up",
"post_attention_layernorm", "post_attention_norm",
"pre_feedforward_layernorm", "ffn_norm",
"post_feedforward_layernorm", "post_ffw_norm",
}
}
+229
View File
@@ -0,0 +1,229 @@
package convert
import (
"bytes"
"encoding/binary"
"encoding/json"
"io"
"math"
"os"
"path/filepath"
"slices"
"testing"
"github.com/ollama/ollama/fs/ggml"
)
func TestConvertEmbeddingGemmaSentenceTransformers(t *testing.T) {
tempDir := t.TempDir()
writeJSONFile(t, filepath.Join(tempDir, "config.json"), map[string]any{
"architectures": []string{"Gemma3TextModel"},
"vocab_size": uint32(4),
"max_position_embeddings": uint32(2048),
"hidden_size": uint32(8),
"num_hidden_layers": uint32(1),
"intermediate_size": uint32(12),
"num_attention_heads": uint32(1),
"num_key_value_heads": uint32(1),
"head_dim": uint32(8),
"rms_norm_eps": float32(1e-6),
"rope_theta": float32(1000000),
"rope_local_base_freq": float32(10000),
"sliding_window": uint32(512),
"use_bidirectional_attention": true,
})
writeJSONFile(t, filepath.Join(tempDir, "tokenizer.json"), map[string]any{
"model": map[string]any{
"vocab": map[string]int{
"<pad>": 0,
"<eos>": 1,
"<bos>": 2,
"<unk>": 3,
},
},
"added_tokens": []map[string]any{
{"id": 4, "content": "<image_soft_token>", "special": true},
},
})
writeJSONFile(t, filepath.Join(tempDir, "modules.json"), []map[string]string{
{"type": "sentence_transformers.models.Transformer", "path": ""},
{"type": "sentence_transformers.models.Pooling", "path": "1_Pooling"},
{"type": "sentence_transformers.models.Dense", "path": "2_Dense"},
{"type": "sentence_transformers.models.Dense", "path": "3_Dense"},
{"type": "sentence_transformers.models.Normalize", "path": "4_Normalize"},
})
writeJSONFile(t, filepath.Join(tempDir, "1_Pooling", "config.json"), map[string]any{
"pooling_mode_mean_tokens": true,
})
writeJSONFile(t, filepath.Join(tempDir, "2_Dense", "config.json"), map[string]any{
"in_features": uint32(8),
"out_features": uint32(16),
"bias": false,
})
writeJSONFile(t, filepath.Join(tempDir, "3_Dense", "config.json"), map[string]any{
"in_features": uint32(16),
"out_features": uint32(8),
"bias": false,
})
writeSafetensorsFile(t, filepath.Join(tempDir, "model.safetensors"), []safetensorFixtureTensor{
{name: "embed_tokens.weight", shape: []int{4, 8}},
{name: "norm.weight", shape: []int{8}},
{name: "layers.0.input_layernorm.weight", shape: []int{8}},
{name: "layers.0.self_attn.q_proj.weight", shape: []int{8, 8}},
})
writeSafetensorsFile(t, filepath.Join(tempDir, "2_Dense", "model.safetensors"), []safetensorFixtureTensor{
{name: "linear.weight", shape: []int{16, 8}},
})
writeSafetensorsFile(t, filepath.Join(tempDir, "3_Dense", "model.safetensors"), []safetensorFixtureTensor{
{name: "linear.weight", shape: []int{8, 16}},
})
f, kv, tensors := convertFull(t, os.DirFS(tempDir))
defer f.Close()
if got := kv.Architecture(); got != "gemma-embedding" {
t.Fatalf("architecture = %q, want gemma-embedding", got)
}
for key, want := range map[string]uint32{
"dense_2_feat_in": 8,
"dense_2_feat_out": 16,
"dense_3_feat_in": 16,
"dense_3_feat_out": 8,
"pooling_type": 1,
"attention.sliding_window": 512,
} {
if got := kv.Uint(key); got != want {
t.Errorf("%s = %d, want %d", key, got, want)
}
}
if got := kv.Float("rope.freq_base_swa"); got != 10000 {
t.Errorf("rope.freq_base_swa = %v, want 10000", got)
}
if got := kv.Strings("tokenizer.ggml.tokens"); len(got) != 4 {
t.Errorf("token count = %d, want 4", len(got))
}
names := tensorNames(tensors)
for _, name := range []string{
"token_embd.weight",
"output_norm.weight",
"blk.0.attn_norm.weight",
"blk.0.attn_q.weight",
"dense_2.weight",
"dense_3.weight",
} {
if !slices.Contains(names, name) {
t.Errorf("missing tensor %s", name)
}
}
assertF32TensorValues(t, f, tensors, "output_norm.weight", 1)
assertF32TensorValues(t, f, tensors, "blk.0.attn_norm.weight", 1)
}
type safetensorFixtureTensor struct {
name string
shape []int
}
func writeJSONFile(t *testing.T, path string, value any) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
bts, err := json.Marshal(value)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, bts, 0o644); err != nil {
t.Fatal(err)
}
}
func writeSafetensorsFile(t *testing.T, path string, tensors []safetensorFixtureTensor) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
offset := 0
metadata := map[string]*tensorData{}
for _, tensor := range tensors {
size := 4
for _, dim := range tensor.shape {
size *= dim
}
metadata[tensor.name] = &tensorData{
Offsets: []int{offset, offset + size},
Type: "F32",
Shape: tensor.shape,
}
offset += size
}
header, err := json.Marshal(metadata)
if err != nil {
t.Fatal(err)
}
var buf bytes.Buffer
if err := binary.Write(&buf, binary.LittleEndian, int64(len(header))); err != nil {
t.Fatal(err)
}
if _, err := buf.Write(header); err != nil {
t.Fatal(err)
}
if _, err := buf.Write(make([]byte, offset)); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
t.Fatal(err)
}
}
func tensorNames(tensors ggml.Tensors) []string {
names := make([]string, 0, len(tensors.Items()))
for _, tensor := range tensors.Items() {
names = append(names, tensor.Name)
}
return names
}
func assertF32TensorValues(t *testing.T, f *os.File, tensors ggml.Tensors, name string, want float32) {
t.Helper()
var tensor *ggml.Tensor
for _, item := range tensors.Items() {
if item.Name == name {
tensor = item
break
}
}
if tensor == nil {
t.Fatalf("missing tensor %s", name)
}
if tensor.Kind != uint32(ggml.TensorTypeF32) {
t.Fatalf("%s kind = %d, want F32", name, tensor.Kind)
}
bts := make([]byte, tensor.Size())
reader := io.NewSectionReader(f, int64(tensors.Offset+tensor.Offset), int64(tensor.Size()))
if _, err := io.ReadFull(reader, bts); err != nil {
t.Fatal(err)
}
for i := 0; i < len(bts); i += 4 {
if got := math.Float32frombits(binary.LittleEndian.Uint32(bts[i:])); got != want {
t.Fatalf("%s[%d] = %v, want %v", name, i/4, got, want)
}
}
}
+43
View File
@@ -2,7 +2,11 @@ package convert
import (
"cmp"
"fmt"
"slices"
"strings"
"github.com/ollama/ollama/fs/ggml"
)
type gemma3Model struct {
@@ -178,3 +182,42 @@ func (p *gemma3Model) Replacements() []string {
"multi_modal_projector", "mm",
}
}
func (p *gemma3Model) TensorsWithTokenizer(ts []Tensor, t *Tokenizer) []*ggml.Tensor {
vocabSize := uint64(0)
if t != nil && t.Vocabulary != nil {
vocabSize = uint64(len(t.Vocabulary.Tokens))
}
var out []*ggml.Tensor
for _, tensor := range ts {
name := tensor.Name()
gt := &ggml.Tensor{
Name: name,
Kind: tensor.Kind(),
Shape: tensor.Shape(),
WriterTo: tensor,
}
if !strings.HasPrefix(name, "v.") && strings.HasSuffix(name, "_norm.weight") {
tensor.SetRepacker(p.addOne)
}
if vocabSize > 0 && name == "token_embd.weight" && len(gt.Shape) >= 2 && gt.Shape[0] > vocabSize {
gt.Shape = slices.Clone(gt.Shape)
embdDim := gt.Shape[1]
gt.Shape[0] = vocabSize
tensor.SetRepacker(func(_ string, data []float32, _ []uint64) ([]float32, error) {
n := vocabSize * embdDim
if uint64(len(data)) < n {
return nil, fmt.Errorf("gemma3 token_embd.weight has %d values, need %d", len(data), n)
}
return data[:n], nil
})
}
out = append(out, gt)
}
return out
}
+34
View File
@@ -0,0 +1,34 @@
package convert
import (
"slices"
"testing"
)
func TestGemma3TensorsWithTokenizerTruncatesPaddedEmbedding(t *testing.T) {
p := gemma3Model{}
embedding := &fakeTensor{
name: "token_embd.weight",
shape: []uint64{5, 2},
data: []float32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9},
}
out := p.TensorsWithTokenizer([]Tensor{embedding}, &Tokenizer{
Vocabulary: &Vocabulary{Tokens: []string{"a", "b", "<image>"}},
})
if len(out) != 1 {
t.Fatalf("expected 1 tensor, got %d", len(out))
}
if got, want := out[0].Shape, []uint64{3, 2}; !slices.Equal(got, want) {
t.Fatalf("token_embd.weight shape = %v, want %v", got, want)
}
got, err := embedding.repacker(embedding.name, embedding.data, embedding.shape)
if err != nil {
t.Fatalf("unexpected repacker error: %v", err)
}
if want := embedding.data[:6]; !slices.Equal(got, want) {
t.Fatalf("truncated embedding = %v, want %v", got, want)
}
}
+51 -21
View File
@@ -1,6 +1,8 @@
package convert
import (
"encoding/json"
"fmt"
"slices"
"strings"
@@ -14,30 +16,58 @@ type gemma3nModel struct {
ModelParameters
TextModel struct {
ActivationSparsityPattern []float32 `json:"activation_sparsity_pattern"`
AltupActiveIdx uint32 `json:"altup_active_idx"`
AltupCoefClip float32 `json:"altup_coef_clip"`
AltupCorrectScale bool `json:"altup_correct_scale"`
AltupLRMultiplier float32 `json:"altup_lr_multiplier"`
AltupNumInputs uint32 `json:"altup_num_inputs"`
HeadDim uint32 `json:"head_dim"`
HiddenSize uint32 `json:"hidden_size"`
HiddenSizePerLayerInput uint32 `json:"hidden_size_per_layer_input"`
IntermediateSize uint32 `json:"intermediate_size"`
MaxPositionEmbeddings uint32 `json:"max_position_embeddings"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumHiddenLayers uint32 `json:"num_hidden_layers"`
NumKeyValueHeads uint32 `json:"num_key_value_heads"`
NumKVSharedLayers uint32 `json:"num_kv_shared_layers"`
RMSNormEPS float32 `json:"rms_norm_eps"`
RopeLocalBaseFreq float32 `json:"rope_local_base_freq"`
RopeTheta float32 `json:"rope_theta"`
SlidingWindow uint32 `json:"sliding_window"`
LayerTypes []string `json:"layer_types"`
ActivationSparsityPattern []float32 `json:"activation_sparsity_pattern"`
AltupActiveIdx uint32 `json:"altup_active_idx"`
AltupCoefClip float32 `json:"altup_coef_clip"`
AltupCorrectScale bool `json:"altup_correct_scale"`
AltupLRMultiplier float32 `json:"altup_lr_multiplier"`
AltupNumInputs uint32 `json:"altup_num_inputs"`
HeadDim uint32 `json:"head_dim"`
HiddenSize uint32 `json:"hidden_size"`
HiddenSizePerLayerInput uint32 `json:"hidden_size_per_layer_input"`
IntermediateSize gemma3nIntermediateSize `json:"intermediate_size"`
MaxPositionEmbeddings uint32 `json:"max_position_embeddings"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumHiddenLayers uint32 `json:"num_hidden_layers"`
NumKeyValueHeads uint32 `json:"num_key_value_heads"`
NumKVSharedLayers uint32 `json:"num_kv_shared_layers"`
RMSNormEPS float32 `json:"rms_norm_eps"`
RopeLocalBaseFreq float32 `json:"rope_local_base_freq"`
RopeTheta float32 `json:"rope_theta"`
SlidingWindow uint32 `json:"sliding_window"`
LayerTypes []string `json:"layer_types"`
} `json:"text_config"`
VisionModel struct{} `json:"vision_config"`
}
type gemma3nIntermediateSize uint32
func (s *gemma3nIntermediateSize) UnmarshalJSON(data []byte) error {
var scalar uint32
if err := json.Unmarshal(data, &scalar); err == nil {
*s = gemma3nIntermediateSize(scalar)
return nil
}
var values []uint32
if err := json.Unmarshal(data, &values); err != nil {
return err
}
if len(values) == 0 {
return fmt.Errorf("intermediate_size must not be empty")
}
first := values[0]
for _, v := range values[1:] {
if v != first {
return fmt.Errorf("intermediate_size values must match")
}
}
*s = gemma3nIntermediateSize(first)
return nil
}
func (m *gemma3nModel) KV(t *Tokenizer) KV {
kv := m.ModelParameters.KV(t)
kv["general.architecture"] = "gemma3n"
@@ -69,7 +99,7 @@ func (m *gemma3nModel) KV(t *Tokenizer) KV {
kv["gemma3n.context_length"] = m.TextModel.MaxPositionEmbeddings
kv["gemma3n.embedding_length_per_layer_input"] = m.TextModel.HiddenSizePerLayerInput
kv["gemma3n.embedding_length"] = m.TextModel.HiddenSize
kv["gemma3n.feed_forward_length"] = m.TextModel.IntermediateSize
kv["gemma3n.feed_forward_length"] = uint32(m.TextModel.IntermediateSize)
kv["gemma3n.head_dim"] = m.TextModel.HeadDim
kv["gemma3n.rope.freq_base_local"] = m.TextModel.RopeLocalBaseFreq
kv["gemma3n.rope.freq_base"] = m.TextModel.RopeTheta
+55
View File
@@ -0,0 +1,55 @@
package convert
import (
"encoding/json"
"testing"
)
func TestGemma3nIntermediateSize(t *testing.T) {
tests := []struct {
name string
json string
want gemma3nIntermediateSize
wantErr bool
}{
{
name: "scalar",
json: `8192`,
want: 8192,
},
{
name: "uniform array",
json: `[8192,8192,8192]`,
want: 8192,
},
{
name: "mixed array",
json: `[8192,4096]`,
wantErr: true,
},
{
name: "empty array",
json: `[]`,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got gemma3nIntermediateSize
err := json.Unmarshal([]byte(tt.json), &got)
if tt.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatal(err)
}
if got != tt.want {
t.Fatalf("got %d, want %d", got, tt.want)
}
})
}
}
+49 -25
View File
@@ -39,48 +39,72 @@ type glm4MoeLiteModel struct {
ExpertWeightsScale float32 `json:"routed_scaling_factor"`
LeadingDenseBlockCount uint32 `json:"first_k_dense_replace"`
ExpertGroupCount uint32 `json:"n_group"`
ExpertGroupUsedCount uint32 `json:"topk_group"`
}
func (p *glm4MoeLiteModel) KV(t *Tokenizer) KV {
kv := p.ModelParameters.KV(t)
kv["general.architecture"] = "glm4moelite"
kv["general.architecture"] = "deepseek2"
kv["general.type"] = "model"
kv["glm4moelite.block_count"] = p.HiddenLayers
kv["deepseek2.block_count"] = p.HiddenLayers
numHeads := p.NumAttentionHeads
numKVHeads := p.NumKeyValueHeads
kv["glm4moelite.attention.head_count"] = numHeads
kv["glm4moelite.attention.head_count_kv"] = numKVHeads
kv["glm4moelite.attention.key_length"] = p.QKNopeHeadDim + p.QKRopeHeadDim
kv["glm4moelite.attention.kv_lora_rank"] = p.KVLoraRank
kv["glm4moelite.attention.layer_norm_rms_epsilon"] = p.RMSNormEPS
kv["glm4moelite.attention.q_lora_rank"] = p.QLoraRank
kv["glm4moelite.attention.value_length"] = p.VHeadDim
kv["glm4moelite.context_length"] = p.MaxPositionEmbeddings
kv["glm4moelite.embedding_length"] = p.HiddenSize
kv["glm4moelite.expert_count"] = p.ExpertCount
kv["glm4moelite.expert_feed_forward_length"] = p.ExpertIntermediateSize
kv["glm4moelite.expert_shared_count"] = p.ExpertSharedCount
kv["deepseek2.attention.head_count"] = numHeads
kv["deepseek2.attention.head_count_kv"] = uint32(1)
kv["deepseek2.attention.key_length"] = p.KVLoraRank + p.QKRopeHeadDim
kv["deepseek2.attention.kv_lora_rank"] = p.KVLoraRank
kv["deepseek2.attention.layer_norm_rms_epsilon"] = p.RMSNormEPS
kv["deepseek2.attention.q_lora_rank"] = p.QLoraRank
kv["deepseek2.attention.value_length"] = p.KVLoraRank
kv["deepseek2.context_length"] = p.MaxPositionEmbeddings
kv["deepseek2.embedding_length"] = p.HiddenSize
kv["deepseek2.expert_count"] = p.ExpertCount
kv["deepseek2.expert_feed_forward_length"] = p.ExpertIntermediateSize
kv["deepseek2.expert_shared_count"] = p.ExpertSharedCount
kv["glm4moelite.expert_gating_func"] = uint32(2)
kv["glm4moelite.expert_used_count"] = p.ExpertUsedCount
kv["glm4moelite.expert_weights_norm"] = p.ExpertWeightsNorm
kv["glm4moelite.expert_weights_scale"] = p.ExpertWeightsScale
kv["glm4moelite.feed_forward_length"] = p.IntermediateSize
kv["glm4moelite.leading_dense_block_count"] = p.LeadingDenseBlockCount
kv["deepseek2.expert_gating_func"] = uint32(2)
kv["deepseek2.expert_group_count"] = cmp.Or(p.ExpertGroupCount, uint32(1))
kv["deepseek2.expert_group_used_count"] = cmp.Or(p.ExpertGroupUsedCount, uint32(1))
kv["deepseek2.expert_used_count"] = p.ExpertUsedCount
kv["deepseek2.expert_weights_norm"] = p.ExpertWeightsNorm
kv["deepseek2.expert_weights_scale"] = p.ExpertWeightsScale
kv["deepseek2.feed_forward_length"] = p.IntermediateSize
kv["deepseek2.leading_dense_block_count"] = p.LeadingDenseBlockCount
kv["glm4moelite.rope.dimension_count"] = p.QKRopeHeadDim
kv["glm4moelite.rope.freq_base"] = cmp.Or(p.RopeTheta, float32(1000000.0))
kv["deepseek2.rope.dimension_count"] = p.QKRopeHeadDim
kv["deepseek2.rope.freq_base"] = cmp.Or(p.RopeTheta, float32(1000000.0))
kv["glm4moelite.attention.key_length_mla"] = p.KVLoraRank + p.QKRopeHeadDim
kv["glm4moelite.attention.value_length_mla"] = p.KVLoraRank
kv["deepseek2.attention.key_length_mla"] = p.QKNopeHeadDim + p.QKRopeHeadDim
kv["deepseek2.attention.value_length_mla"] = p.VHeadDim
kv["tokenizer.ggml.pre"] = "glm4"
setGLM4MoeLiteExtraEOGFromEOSIDs(kv)
return kv
}
func setGLM4MoeLiteExtraEOGFromEOSIDs(kv KV) {
switch ids := kv["tokenizer.ggml.eos_token_ids"].(type) {
case []int32:
if len(ids) >= 2 && ids[1] >= 0 {
kv["tokenizer.ggml.eot_token_id"] = uint32(ids[1])
}
if len(ids) >= 3 && ids[2] >= 0 {
kv["tokenizer.ggml.eom_token_id"] = uint32(ids[2])
}
case []uint32:
if len(ids) >= 2 {
kv["tokenizer.ggml.eot_token_id"] = ids[1]
}
if len(ids) >= 3 {
kv["tokenizer.ggml.eom_token_id"] = ids[2]
}
}
}
func (p *glm4MoeLiteModel) Replacements() []string {
return []string{
"lm_head", "output",
+68
View File
@@ -0,0 +1,68 @@
package convert
import "testing"
func TestGLM4MoeLiteKVUsesLlamaCppMetadata(t *testing.T) {
p := glm4MoeLiteModel{
ModelParameters: ModelParameters{VocabSize: 151552},
MaxPositionEmbeddings: 202752,
HiddenSize: 2048,
HiddenLayers: 47,
IntermediateSize: 10240,
NumAttentionHeads: 20,
NumKeyValueHeads: 20,
RMSNormEPS: 1e-5,
RopeTheta: 1000000,
QKNopeHeadDim: 128,
QKRopeHeadDim: 64,
KVLoraRank: 512,
QLoraRank: 768,
VHeadDim: 128,
ExpertCount: 64,
ExpertSharedCount: 1,
ExpertUsedCount: 4,
ExpertWeightsNorm: true,
ExpertWeightsScale: 1.8,
}
kv := p.KV(&Tokenizer{Vocabulary: &Vocabulary{Model: "gpt2", Tokens: []string{"a"}}})
if got := kv.Architecture(); got != "deepseek2" {
t.Fatalf("architecture = %q, want deepseek2", got)
}
for key, want := range map[string]uint32{
"attention.head_count": 20,
"attention.head_count_kv": 1,
"attention.key_length": 576,
"attention.value_length": 512,
"attention.key_length_mla": 192,
"attention.value_length_mla": 128,
"expert_group_count": 1,
"expert_group_used_count": 1,
"expert_gating_func": 2,
"rope.dimension_count": 64,
} {
if got := kv.Uint(key); got != want {
t.Errorf("%s = %d, want %d", key, got, want)
}
}
if got := kv.String("tokenizer.ggml.pre"); got != "glm4" {
t.Errorf("tokenizer.ggml.pre = %q, want glm4", got)
}
}
func TestGLM4MoeLiteKVPromotesExtraEOSIDs(t *testing.T) {
kv := KV{
"general.architecture": "deepseek2",
"tokenizer.ggml.eos_token_ids": []int32{151329, 151330, 151336},
}
setGLM4MoeLiteExtraEOGFromEOSIDs(kv)
if got := kv.Uint("tokenizer.ggml.eot_token_id"); got != 151330 {
t.Errorf("eot token = %d, want 151330", got)
}
if got := kv.Uint("tokenizer.ggml.eom_token_id"); got != 151336 {
t.Errorf("eom token = %d, want 151336", got)
}
}
+240 -20
View File
@@ -83,6 +83,7 @@ type glmOcrModel struct {
HiddenSize uint32 `json:"hidden_size"`
IntermediateSize uint32 `json:"intermediate_size"`
NumHiddenLayers uint32 `json:"num_hidden_layers"`
NumNextNPredict uint32 `json:"num_nextn_predict_layers"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumKeyValueHeads uint32 `json:"num_key_value_heads"`
HeadDim uint32 `json:"head_dim"`
@@ -131,7 +132,7 @@ type glmOcrModel struct {
} `json:"-"`
}
var _ ModelConverter = (*glmOcrModel)(nil)
var _ MultimodalConverter = (*glmOcrModel)(nil)
func (m *glmOcrModel) parseMore(fsys fs.FS) error {
bts, err := fs.ReadFile(fsys, "preprocessor_config.json")
@@ -145,9 +146,14 @@ func (m *glmOcrModel) parseMore(fsys fs.FS) error {
func (m *glmOcrModel) KV(t *Tokenizer) KV {
kv := m.ModelParameters.KV(t)
kv["general.architecture"] = "glmocr"
applyGlmOcrTokenizerKV(kv, t)
// Text model parameters
kv["glmocr.block_count"] = cmp.Or(m.TextConfig.NumHiddenLayers, 16)
numHiddenLayers := cmp.Or(m.TextConfig.NumHiddenLayers, 16)
kv["glmocr.block_count"] = numHiddenLayers + m.TextConfig.NumNextNPredict
if m.TextConfig.NumNextNPredict > 0 {
kv["glmocr.nextn_predict_layers"] = m.TextConfig.NumNextNPredict
}
kv["glmocr.embedding_length"] = cmp.Or(m.TextConfig.HiddenSize, 1536)
kv["glmocr.attention.head_count"] = cmp.Or(m.TextConfig.NumAttentionHeads, 16)
kv["glmocr.attention.head_count_kv"] = cmp.Or(m.TextConfig.NumKeyValueHeads, 8)
@@ -175,8 +181,6 @@ func (m *glmOcrModel) KV(t *Tokenizer) KV {
kv["glmocr.vision.intermediate_size"] = cmp.Or(m.VisionConfig.IntermediateSize, 4096)
kv["glmocr.vision.attention.layer_norm_rms_epsilon"] = cmp.Or(m.VisionConfig.RMSNormEps, 1e-5)
// Preprocessor-derived image settings (min/max pixels and normalization)
// Note: fs.Config.keyValue() auto-prepends architecture prefix, so use full key
if m.Preprocessor.Size.ShortestEdge > 0 {
kv["glmocr.vision.min_pixels"] = m.Preprocessor.Size.ShortestEdge
}
@@ -190,7 +194,6 @@ func (m *glmOcrModel) KV(t *Tokenizer) KV {
kv["glmocr.vision.image_std"] = m.Preprocessor.ImageStd
}
// Special tokens
kv["glmocr.image_token_id"] = m.ImageTokenID
kv["glmocr.image_start_token_id"] = m.ImageStartTokenID
kv["glmocr.image_end_token_id"] = m.ImageEndTokenID
@@ -201,32 +204,249 @@ func (m *glmOcrModel) KV(t *Tokenizer) KV {
return kv
}
func applyGlmOcrTokenizerKV(kv KV, t *Tokenizer) {
kv["tokenizer.ggml.pre"] = "chatglm-bpe"
if id, ok := glmOcrTokenID(t, "<|endoftext|>"); ok {
kv["tokenizer.ggml.bos_token_id"] = uint32(id)
kv["tokenizer.ggml.unknown_token_id"] = uint32(id)
}
if id, ok := glmOcrTokenID(t, "<|user|>"); ok {
kv["tokenizer.ggml.eot_token_id"] = uint32(id)
}
}
func (m *glmOcrModel) TextKV(t *Tokenizer) KV {
kv := m.ModelParameters.KV(t)
kv["general.architecture"] = "glm4"
applyGlmOcrTokenizerKV(kv, t)
numHiddenLayers := cmp.Or(m.TextConfig.NumHiddenLayers, 16)
kv["block_count"] = numHiddenLayers + m.TextConfig.NumNextNPredict
if m.TextConfig.NumNextNPredict > 0 {
kv["nextn_predict_layers"] = m.TextConfig.NumNextNPredict
}
kv["embedding_length"] = cmp.Or(m.TextConfig.HiddenSize, 1536)
kv["attention.head_count"] = cmp.Or(m.TextConfig.NumAttentionHeads, 16)
kv["attention.head_count_kv"] = cmp.Or(m.TextConfig.NumKeyValueHeads, 8)
headDim := cmp.Or(m.TextConfig.HeadDim, m.TextConfig.HiddenSize/m.TextConfig.NumAttentionHeads)
kv["attention.key_length"] = headDim
kv["attention.value_length"] = headDim
kv["feed_forward_length"] = cmp.Or(m.TextConfig.IntermediateSize, 4608)
kv["attention.layer_norm_rms_epsilon"] = cmp.Or(m.TextConfig.RMSNormEps, 1e-5)
kv["context_length"] = cmp.Or(m.TextConfig.MaxPositionEmbed, 131072)
kv["rope.freq_base"] = cmp.Or(m.TextConfig.RopeParameters.RopeTheta, float32(10000))
partialRotaryFactor := cmp.Or(m.TextConfig.RopeParameters.PartialRotaryFactor, m.TextConfig.PartialRotaryFactor, float32(1.0))
kv["rope.dimension_count"] = uint32(float32(headDim) * partialRotaryFactor)
if len(m.TextConfig.RopeParameters.MRopeSection) > 0 {
sections := append([]int32(nil), m.TextConfig.RopeParameters.MRopeSection...)
for len(sections) < 4 {
sections = append(sections, 0)
}
kv["rope.dimension_sections"] = sections
}
return kv
}
func (m *glmOcrModel) ProjectorKV(*Tokenizer) KV {
kv := KV{
"general.architecture": "clip",
"general.type": "mmproj",
"general.file_type": uint32(1),
"general.quantization_version": uint32(2),
"clip.has_vision_encoder": true,
"clip.projector_type": "glm4v",
"clip.use_silu": true,
"clip.vision.block_count": cmp.Or(m.VisionConfig.Depth, 24),
"clip.vision.embedding_length": cmp.Or(m.VisionConfig.HiddenSize, 1024),
"clip.vision.attention.head_count": cmp.Or(m.VisionConfig.NumHeads, 16),
"clip.vision.image_size": cmp.Or(m.VisionConfig.ImageSize, 336),
"clip.vision.patch_size": cmp.Or(m.VisionConfig.PatchSize, m.Preprocessor.PatchSize, 14),
"clip.vision.spatial_merge_size": cmp.Or(m.VisionConfig.SpatialMergeSize, m.Preprocessor.MergeSize, 2),
"clip.vision.temporal_patch_size": cmp.Or(m.VisionConfig.TemporalPatchSize, m.Preprocessor.TemporalPatchSize, 2),
"clip.vision.projection_dim": cmp.Or(m.VisionConfig.OutHiddenSize, 1536),
"clip.vision.out_hidden_size": cmp.Or(m.VisionConfig.OutHiddenSize, 1536),
"clip.vision.feed_forward_length": cmp.Or(m.VisionConfig.IntermediateSize, 4096),
"clip.vision.intermediate_size": cmp.Or(m.VisionConfig.IntermediateSize, 4096),
"clip.vision.attention.layer_norm_epsilon": cmp.Or(m.VisionConfig.RMSNormEps, 1e-5),
"clip.vision.image_token_id": m.ImageTokenID,
"clip.vision.image_start_token_id": m.ImageStartTokenID,
"clip.vision.image_end_token_id": m.ImageEndTokenID,
}
if m.Preprocessor.Size.ShortestEdge > 0 {
kv["clip.vision.min_pixels"] = m.Preprocessor.Size.ShortestEdge
}
if m.Preprocessor.Size.LongestEdge > 0 {
kv["clip.vision.max_pixels"] = m.Preprocessor.Size.LongestEdge
}
if len(m.Preprocessor.ImageMean) == 3 {
kv["clip.vision.image_mean"] = m.Preprocessor.ImageMean
}
if len(m.Preprocessor.ImageStd) == 3 {
kv["clip.vision.image_std"] = m.Preprocessor.ImageStd
}
return kv
}
func glmOcrTokenID(t *Tokenizer, token string) (int, bool) {
if t == nil || t.Vocabulary == nil {
return 0, false
}
for i, candidate := range t.Vocabulary.Tokens {
if candidate == token {
return i, true
}
}
return 0, false
}
func isGlmOcrVisionTensor(name string) bool {
return strings.HasPrefix(name, "v.") || strings.HasPrefix(name, "mm.")
}
func (m *glmOcrModel) TextTensors(ts []Tensor, t *Tokenizer) []*ggml.Tensor {
textOnly := make([]Tensor, 0, len(ts))
for _, tensor := range ts {
if !isGlmOcrVisionTensor(tensor.Name()) {
textOnly = append(textOnly, tensor)
}
}
return m.Tensors(textOnly)
}
func (m *glmOcrModel) ProjectorTensors(ts []Tensor) []*ggml.Tensor {
var out []*ggml.Tensor
for _, t := range ts {
if !isGlmOcrVisionTensor(t.Name()) {
continue
}
name := t.Name()
switch {
case strings.HasSuffix(name, "patch_embd_0.weight"):
name = strings.Replace(name, "patch_embd_0.weight", "patch_embd.weight", 1)
case strings.HasSuffix(name, "patch_embd_1.weight"):
name = strings.Replace(name, "patch_embd_1.weight", "patch_embd.weight.1", 1)
case strings.HasSuffix(name, "patch_embd.weight.0"):
name = strings.Replace(name, "patch_embd.weight.0", "patch_embd.weight", 1)
}
if strings.HasSuffix(name, "patch_embd.weight") {
shape := t.Shape()
if len(shape) == 5 && shape[2] == 2 {
newShape := []uint64{shape[0], shape[1], shape[3], shape[4]}
t0 := t.Clone()
t0.SetRepacker(func(_ string, data []float32, shape []uint64) ([]float32, error) {
dims := make([]int, len(shape))
for i := range shape {
dims[i] = int(shape[i])
}
var tt tensor.Tensor = tensor.New(tensor.WithShape(dims...), tensor.WithBacking(data))
tt, err := tt.Slice(nil, nil, tensor.S(0, 1), nil, nil)
if err != nil {
return nil, err
}
tt = tensor.Materialize(tt)
newDims := []int{int(shape[0]), int(shape[1]), int(shape[3]), int(shape[4])}
if err := tt.Reshape(newDims...); err != nil {
return nil, err
}
if err := tt.Reshape(tt.Shape().TotalSize()); err != nil {
return nil, err
}
return native.VectorF32(tt.(*tensor.Dense))
})
out = append(out, &ggml.Tensor{
Name: strings.Replace(name, "patch_embd.weight", "patch_embd.weight", 1),
Kind: t.Kind(),
Shape: newShape,
WriterTo: t0,
})
t1 := t.Clone()
t1.SetRepacker(func(_ string, data []float32, shape []uint64) ([]float32, error) {
dims := make([]int, len(shape))
for i := range shape {
dims[i] = int(shape[i])
}
var tt tensor.Tensor = tensor.New(tensor.WithShape(dims...), tensor.WithBacking(data))
tt, err := tt.Slice(nil, nil, tensor.S(1, 2), nil, nil)
if err != nil {
return nil, err
}
tt = tensor.Materialize(tt)
newDims := []int{int(shape[0]), int(shape[1]), int(shape[3]), int(shape[4])}
if err := tt.Reshape(newDims...); err != nil {
return nil, err
}
if err := tt.Reshape(tt.Shape().TotalSize()); err != nil {
return nil, err
}
return native.VectorF32(tt.(*tensor.Dense))
})
out = append(out, &ggml.Tensor{
Name: strings.Replace(name, "patch_embd.weight", "patch_embd.weight.1", 1),
Kind: t.Kind(),
Shape: newShape,
WriterTo: t1,
})
continue
}
}
out = append(out, &ggml.Tensor{
Name: name,
Kind: t.Kind(),
Shape: t.Shape(),
WriterTo: t,
})
}
return out
}
func (m *glmOcrModel) Tensors(ts []Tensor) []*ggml.Tensor {
var out []*ggml.Tensor
// Skip layers >= num_hidden_layers (Multi-Token Prediction layers not needed for basic inference)
numLayers := int(cmp.Or(m.TextConfig.NumHiddenLayers, 16))
skipLayer := func(name string) bool {
// Tensor names are already replaced to "blk.N.xxx" format
re := regexp.MustCompile(`^blk\.(\d+)`)
matches := re.FindStringSubmatch(name)
maxLayers := numLayers + int(m.TextConfig.NumNextNPredict)
layerRe := regexp.MustCompile(`^blk\.(\d+)`)
layerIndex := func(name string) (int, bool) {
matches := layerRe.FindStringSubmatch(name)
if matches == nil {
return false
return 0, false
}
blkNum, err := strconv.Atoi(matches[1])
if err != nil {
return false
return 0, false
}
return blkNum >= numLayers
return blkNum, true
}
for _, t := range ts {
name := t.Name()
// Skip next-n prediction layers (layers >= num_hidden_layers)
if skipLayer(name) {
blkNum, hasLayer := layerIndex(name)
if hasLayer && blkNum >= maxLayers {
continue
}
if hasLayer && blkNum >= numLayers {
switch {
case strings.HasSuffix(name, ".embed_tokens.weight"):
name = strings.Replace(name, ".embed_tokens.weight", ".nextn.embed_tokens.weight", 1)
case strings.HasSuffix(name, ".eh_proj.weight"):
name = strings.Replace(name, ".eh_proj.weight", ".nextn.eh_proj.weight", 1)
case strings.HasSuffix(name, ".enorm.weight"):
name = strings.Replace(name, ".enorm.weight", ".nextn.enorm.weight", 1)
case strings.HasSuffix(name, ".hnorm.weight"):
name = strings.Replace(name, ".hnorm.weight", ".nextn.hnorm.weight", 1)
case strings.HasSuffix(name, ".shared_head.head.weight"):
name = strings.Replace(name, ".shared_head.head.weight", ".nextn.shared_head_head.weight", 1)
case strings.HasSuffix(name, ".shared_head.norm.weight"):
name = strings.Replace(name, ".shared_head.norm.weight", ".nextn.shared_head_norm.weight", 1)
}
}
// Split ffn_gate_up into separate gate and up projections
if strings.Contains(name, "ffn_gate_up") {
@@ -440,16 +660,16 @@ func (m *glmOcrModel) Replacements() []string {
"self_attn.q_proj", "attn_q",
"self_attn.k_proj", "attn_k",
"self_attn.v_proj", "attn_v",
"self_attn.o_proj", "attn_out",
"self_attn.o_proj", "attn_output",
// Language model norms
"input_layernorm", "attn_norm",
"post_attention_layernorm", "ffn_norm",
"post_self_attn_layernorm", "post_attn_norm",
"post_mlp_layernorm", "post_ffn_norm",
"post_self_attn_layernorm", "post_attention_norm",
"post_mlp_layernorm", "post_ffw_norm",
// Language model MLP (remove mlp. prefix so ffn_* names work)
"mlp.gate_up_proj", "ffn_gate_up",
// Language model MLP
"mlp.gate_up_proj", "ffn_up",
"mlp.down_proj", "ffn_down",
}
}
+36 -23
View File
@@ -30,7 +30,11 @@ type gptossModel struct {
RopeTheta float32 `json:"rope_theta"`
RopeScalingFactor float32 `json:"rope_scaling_factor"`
RopeScaling struct {
Factor float32 `json:"factor"`
Type string `json:"rope_type"`
Factor float32 `json:"factor"`
OriginalMaxPositionEmbeddings uint32 `json:"original_max_position_embeddings"`
BetaFast float32 `json:"beta_fast"`
BetaSlow float32 `json:"beta_slow"`
} `json:"rope_scaling"`
SlidingWindow uint32 `json:"sliding_window"`
}
@@ -39,23 +43,32 @@ var _ ModelConverter = (*gptossModel)(nil)
func (m *gptossModel) KV(t *Tokenizer) KV {
kv := m.ModelParameters.KV(t)
kv["general.architecture"] = "gptoss"
kv["general.architecture"] = "gpt-oss"
kv["general.file_type"] = uint32(4)
kv["gptoss.context_length"] = cmp.Or(m.MaxPositionEmbeddings, uint32(m.RopeScalingFactor*float32(m.InitialContextLength)))
kv["gptoss.block_count"] = m.HiddenLayers
kv["gptoss.embedding_length"] = m.HiddenSize
kv["gptoss.feed_forward_length"] = m.IntermediateSize
kv["gptoss.expert_count"] = cmp.Or(m.Experts, m.LocalExperts)
kv["gptoss.expert_used_count"] = m.ExpertsPerToken
kv["gptoss.attention.head_count"] = m.AttentionHeads
kv["gptoss.attention.head_count_kv"] = m.KeyValueHeads
kv["gptoss.attention.key_length"] = m.HeadDim
kv["gptoss.attention.value_length"] = m.HeadDim
kv["gptoss.attention.layer_norm_rms_epsilon"] = cmp.Or(m.RMSNormEpsilon, 1e-5)
kv["gptoss.attention.sliding_window"] = m.SlidingWindow
kv["gptoss.rope.freq_base"] = m.RopeTheta
kv["gptoss.rope.scaling.factor"] = cmp.Or(m.RopeScalingFactor, m.RopeScaling.Factor)
kv["gptoss.rope.scaling.original_context_length"] = m.InitialContextLength
kv["gpt-oss.context_length"] = cmp.Or(m.MaxPositionEmbeddings, uint32(m.RopeScalingFactor*float32(m.InitialContextLength)))
kv["gpt-oss.block_count"] = m.HiddenLayers
kv["gpt-oss.embedding_length"] = m.HiddenSize
kv["gpt-oss.feed_forward_length"] = m.IntermediateSize
kv["gpt-oss.expert_feed_forward_length"] = m.IntermediateSize
kv["gpt-oss.expert_count"] = cmp.Or(m.Experts, m.LocalExperts)
kv["gpt-oss.expert_used_count"] = m.ExpertsPerToken
kv["gpt-oss.attention.head_count"] = m.AttentionHeads
kv["gpt-oss.attention.head_count_kv"] = m.KeyValueHeads
kv["gpt-oss.attention.key_length"] = m.HeadDim
kv["gpt-oss.attention.value_length"] = m.HeadDim
kv["gpt-oss.attention.layer_norm_rms_epsilon"] = cmp.Or(m.RMSNormEpsilon, 1e-5)
kv["gpt-oss.attention.sliding_window"] = m.SlidingWindow
kv["gpt-oss.rope.freq_base"] = m.RopeTheta
kv["gpt-oss.rope.scaling.type"] = cmp.Or(m.RopeScaling.Type, "yarn")
kv["gpt-oss.rope.scaling.factor"] = cmp.Or(m.RopeScalingFactor, m.RopeScaling.Factor)
kv["gpt-oss.rope.scaling.original_context_length"] = cmp.Or(m.RopeScaling.OriginalMaxPositionEmbeddings, m.InitialContextLength)
if m.RopeScaling.BetaFast != 0 {
kv["gpt-oss.rope.scaling.yarn_beta_fast"] = m.RopeScaling.BetaFast
}
if m.RopeScaling.BetaSlow != 0 {
kv["gpt-oss.rope.scaling.yarn_beta_slow"] = m.RopeScaling.BetaSlow
}
kv["tokenizer.ggml.pre"] = "gpt-4o"
kv["tokenizer.ggml.bos_token_id"] = uint32(199998) // <|startoftext|>
kv["tokenizer.ggml.add_bos_token"] = false
kv["tokenizer.ggml.eos_token_id"] = uint32(199999) // <|endoftext|>
@@ -152,9 +165,9 @@ func (m *gptossModel) Replacements() []string {
"self_attn.q_proj", "attn_q",
"self_attn.k_proj", "attn_k",
"self_attn.v_proj", "attn_v",
"self_attn.o_proj", "attn_out",
"self_attn.sinks", "attn_sinks",
"post_attention_layernorm", "ffn_norm",
"self_attn.o_proj", "attn_output",
"self_attn.sinks", "attn_sinks.weight",
"post_attention_layernorm", "post_attention_norm",
"mlp.router", "ffn_gate_inp",
"mlp.experts.gate_up_proj_", "ffn_gate_up_exps.",
"mlp.experts.down_proj_", "ffn_down_exps.",
@@ -169,9 +182,9 @@ func (m *gptossModel) Replacements() []string {
"block", "blk",
"attn.norm", "attn_norm",
"attn.qkv", "attn_qkv",
"attn.sinks", "attn_sinks",
"attn.out", "attn_out",
"mlp.norm", "ffn_norm",
"attn.sinks", "attn_sinks.weight",
"attn.out", "attn_output",
"mlp.norm", "post_attention_norm",
"mlp.gate", "ffn_gate_inp",
"mlp.mlp1_", "ffn_gate_up_exps.",
"mlp.mlp2_", "ffn_down_exps.",
+73
View File
@@ -0,0 +1,73 @@
package convert
import (
"strings"
"testing"
)
func TestGptOssCreatesLlamaCppMetadataAndNames(t *testing.T) {
m := &gptossModel{
HiddenLayers: 24,
MaxPositionEmbeddings: 131072,
HiddenSize: 2880,
IntermediateSize: 2880,
AttentionHeads: 64,
KeyValueHeads: 8,
HeadDim: 64,
LocalExperts: 32,
ExpertsPerToken: 4,
RopeTheta: 150000,
InitialContextLength: 4096,
SlidingWindow: 128,
}
m.RopeScaling.Type = "yarn"
m.RopeScaling.Factor = 32
m.RopeScaling.OriginalMaxPositionEmbeddings = 4096
m.RopeScaling.BetaFast = 32
m.RopeScaling.BetaSlow = 1
kv := m.KV(&Tokenizer{Vocabulary: &Vocabulary{Model: "gpt2"}, Pre: "default"})
for k, want := range map[string]any{
"general.architecture": "gpt-oss",
"tokenizer.ggml.pre": "gpt-4o",
"gpt-oss.context_length": uint32(131072),
"gpt-oss.expert_feed_forward_length": uint32(2880),
"gpt-oss.rope.scaling.type": "yarn",
"gpt-oss.rope.scaling.factor": float32(32),
"gpt-oss.rope.scaling.original_context_length": uint32(4096),
"gpt-oss.rope.scaling.yarn_beta_fast": float32(32),
"gpt-oss.rope.scaling.yarn_beta_slow": float32(1),
} {
if got := kv[k]; got != want {
t.Fatalf("%s = %v (%T), want %v (%T)", k, got, got, want, want)
}
}
if _, ok := kv["gptoss.context_length"]; ok {
t.Fatal("unexpected Ollama-format gptoss metadata")
}
replacer := strings.NewReplacer(m.Replacements()...)
for name, want := range map[string]string{
"model.layers.0.self_attn.o_proj.weight": "blk.0.attn_output.weight",
"model.layers.0.self_attn.sinks": "blk.0.attn_sinks.weight",
"model.layers.0.post_attention_layernorm.weight": "blk.0.post_attention_norm.weight",
"model.layers.0.mlp.experts.gate_up_proj_blocks": "blk.0.ffn_gate_up_exps.blocks",
"model.layers.0.mlp.experts.down_proj_scales": "blk.0.ffn_down_exps.scales",
} {
if got := replacer.Replace(name); got != want {
t.Fatalf("Replace(%q) = %q, want %q", name, got, want)
}
}
m.MaxPositionEmbeddings = 0
replacer = strings.NewReplacer(m.Replacements()...)
for name, want := range map[string]string{
"block.0.attn.out.weight": "blk.0.attn_output.weight",
"block.0.attn.sinks": "blk.0.attn_sinks.weight",
"block.0.mlp.norm.weight": "blk.0.post_attention_norm.weight",
} {
if got := replacer.Replace(name); got != want {
t.Fatalf("Replace(%q) = %q, want %q", name, got, want)
}
}
}
+37 -26
View File
@@ -34,8 +34,6 @@ type llamaModel struct {
LowFrequencyFactor float32 `json:"low_freq_factor"`
HighFrequencyFactor float32 `json:"high_freq_factor"`
OriginalMaxPositionEmbeddings uint32 `json:"original_max_position_embeddings"`
factors ropeFactor
} `json:"rope_scaling"`
RMSNormEPS float32 `json:"rms_norm_eps"`
LayerNormEPS float32 `json:"layer_norm_eps"`
@@ -83,27 +81,6 @@ func (p *llamaModel) KV(t *Tokenizer) KV {
if p.RopeScaling.Type == "linear" {
kv["llama.rope.scaling.type"] = p.RopeScaling.Type
kv["llama.rope.scaling.factor"] = p.RopeScaling.Factor
} else if p.RopeScaling.RopeType == "llama3" {
dim := p.HiddenSize / p.NumAttentionHeads
for i := uint32(0); i < dim; i += 2 {
factor := cmp.Or(p.RopeScaling.Factor, 8.0)
factorLow := cmp.Or(p.RopeScaling.LowFrequencyFactor, 1.0)
factorHigh := cmp.Or(p.RopeScaling.HighFrequencyFactor, 4.0)
original := cmp.Or(p.RopeScaling.OriginalMaxPositionEmbeddings, 8192)
lambdaLow := float32(original) / factorLow
lambdaHigh := float32(original) / factorHigh
lambda := 2 * math.Pi * math.Pow(float64(p.RopeTheta), float64(i)/float64(dim))
if lambda < float64(lambdaHigh) {
p.RopeScaling.factors = append(p.RopeScaling.factors, 1.0)
} else if lambda > float64(lambdaLow) {
p.RopeScaling.factors = append(p.RopeScaling.factors, factor)
} else {
smooth := (float32(original)/float32(lambda) - factorLow) / (factorHigh - factorLow)
p.RopeScaling.factors = append(p.RopeScaling.factors, 1.0/((1-smooth)/factor+smooth))
}
}
}
if p.NumKeyValueHeads > 0 {
@@ -129,12 +106,12 @@ func (p *llamaModel) KV(t *Tokenizer) KV {
func (p *llamaModel) Tensors(ts []Tensor) []*ggml.Tensor {
var out []*ggml.Tensor
if p.RopeScaling.factors != nil {
if factors := p.ropeFactors(); factors != nil {
out = append(out, &ggml.Tensor{
Name: "rope_freqs.weight",
Kind: 0,
Shape: []uint64{uint64(len(p.RopeScaling.factors))},
WriterTo: p.RopeScaling.factors,
Shape: []uint64{uint64(len(factors))},
WriterTo: factors,
})
}
@@ -157,6 +134,40 @@ func (p *llamaModel) Tensors(ts []Tensor) []*ggml.Tensor {
return out
}
func (p *llamaModel) ropeFactors() ropeFactor {
if p.RopeScaling.RopeType != "llama3" || p.HiddenSize == 0 || p.NumAttentionHeads == 0 || p.RopeTheta == 0 {
return nil
}
dim := p.HiddenSize / p.NumAttentionHeads
if dim == 0 {
return nil
}
factors := make(ropeFactor, 0, dim/2)
for i := uint32(0); i < dim; i += 2 {
factor := cmp.Or(p.RopeScaling.Factor, float32(8))
factorLow := cmp.Or(p.RopeScaling.LowFrequencyFactor, float32(1))
factorHigh := cmp.Or(p.RopeScaling.HighFrequencyFactor, float32(4))
original := cmp.Or(p.RopeScaling.OriginalMaxPositionEmbeddings, uint32(8192))
lambdaLow := float32(original) / factorLow
lambdaHigh := float32(original) / factorHigh
lambda := 2 * math.Pi * math.Pow(float64(p.RopeTheta), float64(i)/float64(dim))
if lambda < float64(lambdaHigh) {
factors = append(factors, 1)
} else if lambda > float64(lambdaLow) {
factors = append(factors, factor)
} else {
smooth := (float32(original)/float32(lambda) - factorLow) / (factorHigh - factorLow)
factors = append(factors, 1/((1-smooth)/factor+smooth))
}
}
return factors
}
func (p *llamaModel) Replacements() []string {
return []string{
"lm_head", "output",
+34
View File
@@ -0,0 +1,34 @@
package convert
import "testing"
func TestLlama3RopeFactorsTensorDoesNotDependOnKVOrder(t *testing.T) {
m := &llamaModel{
HiddenSize: 2048,
NumAttentionHeads: 32,
RopeTheta: 500000,
}
m.RopeScaling.RopeType = "llama3"
m.RopeScaling.Factor = 32
m.RopeScaling.LowFrequencyFactor = 1
m.RopeScaling.HighFrequencyFactor = 4
m.RopeScaling.OriginalMaxPositionEmbeddings = 8192
tensors := m.Tensors(nil)
if len(tensors) != 1 {
t.Fatalf("expected rope tensor only, got %d tensors", len(tensors))
}
if tensors[0].Name != "rope_freqs.weight" {
t.Fatalf("expected rope_freqs.weight, got %q", tensors[0].Name)
}
if len(tensors[0].Shape) != 1 || tensors[0].Shape[0] != 32 {
t.Fatalf("expected rope tensor shape [32], got %v", tensors[0].Shape)
}
_ = m.KV(&Tokenizer{Vocabulary: &Vocabulary{}})
afterKV := m.Tensors(nil)
if len(afterKV) != 1 || afterKV[0].Name != "rope_freqs.weight" {
t.Fatalf("expected one rope tensor after KV call, got %#v", afterKV)
}
}
+4 -7
View File
@@ -79,20 +79,17 @@ func (p *mistral3Model) KV(t *Tokenizer) KV {
kv["mistral3.rope.freq_base"] = cmp.Or(p.TextModel.RopeTheta, p.TextModel.RopeParameters.RopeTheta)
kv["mistral3.rope.scaling.factor"] = p.TextModel.RopeParameters.Factor
kv["mistral3.rope.scaling.type"] = p.TextModel.RopeParameters.RopeType
kv["mistral3.rope.scaling.beta_fast"] = p.TextModel.RopeParameters.BetaFast
kv["mistral3.rope.scaling.beta_slow"] = p.TextModel.RopeParameters.BetaSlow
kv["mistral3.rope.scaling.yarn_beta_fast"] = p.TextModel.RopeParameters.BetaFast
kv["mistral3.rope.scaling.yarn_beta_slow"] = p.TextModel.RopeParameters.BetaSlow
if p.TextModel.RopeParameters.Mscale != nil {
kv["mistral3.rope.scaling.mscale"] = *p.TextModel.RopeParameters.Mscale
}
if p.TextModel.RopeParameters.MscaleAllDim != nil {
kv["mistral3.rope.scaling.mscale_all_dim"] = *p.TextModel.RopeParameters.MscaleAllDim
kv["mistral3.rope.scaling.yarn_log_multiplier"] = *p.TextModel.RopeParameters.MscaleAllDim
}
if p.TextModel.RopeParameters.OrigMaxPositionEmbeddings > 0 {
kv["mistral3.rope.scaling.original_context_length"] = p.TextModel.RopeParameters.OrigMaxPositionEmbeddings
}
if p.TextModel.RopeParameters.Llama4ScalingBeta != nil {
kv["mistral3.rope.scaling_beta"] = *p.TextModel.RopeParameters.Llama4ScalingBeta
kv["mistral3.attention.temperature_scale"] = *p.TextModel.RopeParameters.Llama4ScalingBeta
}
// Vision configuration
+4 -9
View File
@@ -58,24 +58,19 @@ func (p *mistral3CausalModel) KV(t *Tokenizer) KV {
kv["mistral3.rope.freq_base"] = cmp.Or(p.RopeTheta, p.RopeParameters.RopeTheta)
kv["mistral3.rope.scaling.factor"] = p.RopeParameters.Factor
kv["mistral3.rope.scaling.type"] = p.RopeParameters.RopeType
kv["mistral3.rope.scaling.beta_fast"] = p.RopeParameters.BetaFast
kv["mistral3.rope.scaling.beta_slow"] = p.RopeParameters.BetaSlow
if p.RopeParameters.Mscale != nil {
kv["mistral3.rope.scaling.mscale"] = *p.RopeParameters.Mscale
}
kv["mistral3.rope.scaling.yarn_beta_fast"] = p.RopeParameters.BetaFast
kv["mistral3.rope.scaling.yarn_beta_slow"] = p.RopeParameters.BetaSlow
if p.RopeParameters.MscaleAllDim != nil {
kv["mistral3.rope.scaling.mscale_all_dim"] = *p.RopeParameters.MscaleAllDim
kv["mistral3.rope.scaling.yarn_log_multiplier"] = *p.RopeParameters.MscaleAllDim
}
if p.RopeParameters.OrigMaxPositionEmbeddings > 0 {
kv["mistral3.rope.scaling.original_context_length"] = p.RopeParameters.OrigMaxPositionEmbeddings
kv["mistral3.rope.scaling_beta"] = *p.RopeParameters.Llama4ScalingBeta
}
if p.RopeParameters.Llama4ScalingBeta != nil {
kv["mistral3.rope.scaling_beta"] = *p.RopeParameters.Llama4ScalingBeta
kv["mistral3.attention.temperature_scale"] = *p.RopeParameters.Llama4ScalingBeta
}
return kv
+70
View File
@@ -0,0 +1,70 @@
package convert
import "testing"
func TestMistral3KVUsesLlamaCppRopeScalingKeys(t *testing.T) {
mscale := float32(0.75)
mscaleAllDim := float32(0)
temperatureScale := float32(0.125)
multimodal := &mistral3Model{}
multimodal.TextModel.NumAttentionHeads = 1
multimodal.TextModel.HeadDim = 64
multimodal.TextModel.RopeParameters.BetaFast = 32
multimodal.TextModel.RopeParameters.BetaSlow = 1
multimodal.TextModel.RopeParameters.Mscale = &mscale
multimodal.TextModel.RopeParameters.MscaleAllDim = &mscaleAllDim
multimodal.TextModel.RopeParameters.Llama4ScalingBeta = &temperatureScale
causal := &mistral3CausalModel{NumAttentionHeads: 1, HeadDim: 64}
causal.RopeParameters.BetaFast = 32
causal.RopeParameters.BetaSlow = 1
causal.RopeParameters.Mscale = &mscale
causal.RopeParameters.MscaleAllDim = &mscaleAllDim
causal.RopeParameters.Llama4ScalingBeta = &temperatureScale
tests := []struct {
name string
kv KV
}{
{name: "multimodal", kv: multimodal.KV(mistralTestTokenizer())},
{name: "causal", kv: causal.KV(mistralTestTokenizer())},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assertKVEquals(t, tt.kv, "mistral3.rope.scaling.yarn_beta_fast", float32(32))
assertKVEquals(t, tt.kv, "mistral3.rope.scaling.yarn_beta_slow", float32(1))
assertKVEquals(t, tt.kv, "mistral3.rope.scaling.yarn_log_multiplier", mscaleAllDim)
assertKVEquals(t, tt.kv, "mistral3.attention.temperature_scale", temperatureScale)
for _, key := range []string{
"mistral3.rope.scaling.beta_fast",
"mistral3.rope.scaling.beta_slow",
"mistral3.rope.scaling.mscale",
"mistral3.rope.scaling.mscale_all_dim",
"mistral3.rope.scaling_beta",
} {
if _, ok := tt.kv[key]; ok {
t.Fatalf("unexpected legacy key %q", key)
}
}
})
}
}
func mistralTestTokenizer() *Tokenizer {
return &Tokenizer{Vocabulary: &Vocabulary{}}
}
func assertKVEquals[T comparable](t *testing.T, kv KV, key string, want T) {
t.Helper()
got, ok := kv[key]
if !ok {
t.Fatalf("missing key %q", key)
}
if got != want {
t.Fatalf("%s = %v, want %v", key, got, want)
}
}
+4 -2
View File
@@ -131,8 +131,10 @@ type radioConfig struct {
} `json:"args"`
}
var _ ModelConverter = (*nemotronHModel)(nil)
var _ ModelConverter = (*nemotronHNanoVLModel)(nil)
var (
_ ModelConverter = (*nemotronHModel)(nil)
_ ModelConverter = (*nemotronHNanoVLModel)(nil)
)
func (n *nemotronHNanoVLModel) parseMore(fsys fs.FS) error {
if n.MaxSequenceLength > 0 {
+15 -15
View File
@@ -36,39 +36,39 @@ var _ ModelConverter = (*olmoModel)(nil)
func (p *olmoModel) KV(t *Tokenizer) KV {
kv := p.ModelParameters.KV(t)
kv["general.architecture"] = "olmo3"
kv["olmo3.block_count"] = p.NumHiddenLayers
kv["olmo3.context_length"] = p.MaxPositionEmbeddings
kv["olmo3.embedding_length"] = p.HiddenSize
kv["olmo3.feed_forward_length"] = p.IntermediateSize
kv["olmo3.attention.head_count"] = p.NumAttentionHeads
kv["olmo3.attention.head_count_kv"] = cmp.Or(p.NumKeyValueHeads, p.NumAttentionHeads)
kv["general.architecture"] = "olmo2"
kv["olmo2.block_count"] = p.NumHiddenLayers
kv["olmo2.context_length"] = p.MaxPositionEmbeddings
kv["olmo2.embedding_length"] = p.HiddenSize
kv["olmo2.feed_forward_length"] = p.IntermediateSize
kv["olmo2.attention.head_count"] = p.NumAttentionHeads
kv["olmo2.attention.head_count_kv"] = cmp.Or(p.NumKeyValueHeads, p.NumAttentionHeads)
if p.RopeTheta > 0 {
kv["olmo3.rope.freq_base"] = p.RopeTheta
kv["olmo2.rope.freq_base"] = p.RopeTheta
}
if p.RopeScaling != nil {
if p.RopeScaling.Factor > 0 {
kv["olmo3.rope.scaling.factor"] = p.RopeScaling.Factor
kv["olmo2.rope.scaling.factor"] = p.RopeScaling.Factor
}
if p.RopeScaling.OriginalMaxPositionEmbeds > 0 {
kv["olmo3.rope.scaling.original_context_length"] = p.RopeScaling.OriginalMaxPositionEmbeds
kv["olmo2.rope.scaling.original_context_length"] = p.RopeScaling.OriginalMaxPositionEmbeds
}
if p.RopeScaling.AttentionFactor > 0 {
kv["olmo3.rope.scaling.attn_factor"] = p.RopeScaling.AttentionFactor
kv["olmo2.rope.scaling.attn_factor"] = p.RopeScaling.AttentionFactor
}
if p.RopeScaling.RopeType != "" {
kv["olmo3.rope.scaling.type"] = p.RopeScaling.RopeType
kv["olmo2.rope.scaling.type"] = p.RopeScaling.RopeType
}
}
if p.RMSNormEPS > 0 {
kv["olmo3.attention.layer_norm_rms_epsilon"] = p.RMSNormEPS
kv["olmo2.attention.layer_norm_rms_epsilon"] = p.RMSNormEPS
}
if p.SlidingWindow > 0 {
kv["olmo3.attention.sliding_window"] = p.SlidingWindow
kv["olmo2.attention.sliding_window"] = p.SlidingWindow
}
if len(p.LayerTypes) > 0 {
@@ -76,7 +76,7 @@ func (p *olmoModel) KV(t *Tokenizer) KV {
for i, layerType := range p.LayerTypes {
slidingPattern[i] = (layerType == "sliding_attention")
}
kv["olmo3.attention.sliding_window_pattern"] = slidingPattern
kv["olmo2.attention.sliding_window_pattern"] = slidingPattern
}
return kv
+639 -6
View File
@@ -1,15 +1,24 @@
package convert
import (
"bufio"
"bytes"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"io/fs"
"maps"
"math"
"os"
"slices"
"strconv"
"strings"
"github.com/d4l3k/go-bfloat16"
"github.com/pdevine/tensor"
"github.com/pdevine/tensor/native"
"github.com/x448/float16"
"github.com/ollama/ollama/fs/ggml"
)
@@ -32,6 +41,8 @@ type qwen3NextTextConfig struct {
MaxPositionEmbeddings uint32 `json:"max_position_embeddings"`
HiddenSize uint32 `json:"hidden_size"`
NumHiddenLayers uint32 `json:"num_hidden_layers"`
NumNextNPredictLayers uint32 `json:"num_nextn_predict_layers"`
MTPNumHiddenLayers uint32 `json:"mtp_num_hidden_layers"`
IntermediateSize uint32 `json:"intermediate_size"`
NumAttentionHeads uint32 `json:"num_attention_heads"`
NumKeyValueHeads uint32 `json:"num_key_value_heads"`
@@ -66,8 +77,11 @@ type qwen3NextTextConfig struct {
type qwen3NextVisionConfig struct {
Depth uint32 `json:"depth"`
HiddenSize uint32 `json:"hidden_size"`
IntermediateSize uint32 `json:"intermediate_size"`
NumHeads uint32 `json:"num_heads"`
NumPositionEmbeddings uint32 `json:"num_position_embeddings"`
InChannels uint32 `json:"in_channels"`
OutHiddenSize uint32 `json:"out_hidden_size"`
PatchSize uint32 `json:"patch_size"`
SpatialMergeSize uint32 `json:"spatial_merge_size"`
RMSNormEps float32 `json:"layer_norm_epsilon"`
@@ -96,12 +110,25 @@ type qwen3NextModel struct {
VisionEndTokenID uint32 `json:"vision_end_token_id"`
}
var _ ModelConverter = (*qwen3NextModel)(nil)
var (
_ ModelConverter = (*qwen3NextModel)(nil)
_ MultimodalConverter = (*qwen3NextModel)(nil)
)
func (q *qwen3NextModel) parseMore(fsys fs.FS) error {
if q.TextConfig != nil {
q.qwen3NextTextConfig = *q.TextConfig
}
if q.NumNextNPredictLayers == 0 {
q.NumNextNPredictLayers = q.MTPNumHiddenLayers
}
if q.NumNextNPredictLayers == 0 {
nextn, err := qwen3NextInferNextNPredictLayers(fsys)
if err != nil {
return err
}
q.NumNextNPredictLayers = nextn
}
if q.RopeTheta == 0 {
q.RopeTheta = q.RopeParameters.RopeTheta
@@ -182,6 +209,150 @@ func (q *qwen3NextModel) parseMore(fsys fs.FS) error {
return nil
}
func qwen3NextInferNextNPredictLayers(fsys fs.FS) (uint32, error) {
paths, err := fs.Glob(fsys, "*.safetensors")
if err != nil {
return 0, err
}
maxLayer := -1
hasMTP := false
for _, p := range paths {
f, err := fsys.Open(p)
if err != nil {
return 0, err
}
var n int64
if err := binary.Read(f, binary.LittleEndian, &n); err != nil {
f.Close()
return 0, err
}
b := bytes.NewBuffer(make([]byte, 0, n))
if _, err = io.CopyN(b, f, n); err != nil {
f.Close()
return 0, err
}
f.Close()
var headers map[string]safetensorMetadata
if err := json.NewDecoder(b).Decode(&headers); err != nil {
return 0, err
}
for name, value := range headers {
if value.Type == "" || !strings.HasPrefix(name, "mtp.") {
continue
}
hasMTP = true
rest := strings.TrimPrefix(name, "mtp.layers.")
layer, suffix, ok := strings.Cut(rest, ".")
if !ok {
continue
}
n, err := strconv.Atoi(layer)
if err == nil && n > maxLayer && suffix != "" {
maxLayer = n
}
}
}
if maxLayer >= 0 {
return uint32(maxLayer + 1), nil
}
if hasMTP {
return 1, nil
}
return 0, nil
}
func ConvertQwen35MTPDraft(fsys fs.FS, f *os.File, baseKV ggml.KV, baseTensors []*ggml.Tensor) error {
arch := baseKV.Architecture()
if arch != "qwen35" && arch != "qwen35moe" {
return fmt.Errorf("MTP draft safetensors require a qwen3.5 base model, got %q", arch)
}
baseBlocks := baseKV.Uint("block_count")
if baseBlocks == 0 {
return fmt.Errorf("MTP draft safetensors require a base model with block_count")
}
if baseKV.Uint("nextn_predict_layers") > 0 {
return fmt.Errorf("MTP draft safetensors require a base model without embedded MTP layers")
}
nextn, err := qwen3NextInferNextNPredictLayers(fsys)
if err != nil {
return err
}
if nextn == 0 {
return fmt.Errorf("MTP draft safetensors did not contain mtp tensors")
}
q := &qwen3NextModel{
qwen3NextTextConfig: qwen3NextTextConfig{
NumHiddenLayers: baseBlocks,
NumNextNPredictLayers: nextn,
},
}
ts, err := parseTensors(fsys, strings.NewReplacer(q.Replacements()...))
if err != nil {
return err
}
if err := ensureUniqueTensorNames(ts); err != nil {
return err
}
mtpTensors := q.Tensors(ts)
if len(mtpTensors) == 0 {
return fmt.Errorf("MTP draft safetensors did not produce GGUF tensors")
}
for _, tensor := range mtpTensors {
if !qwen35MTPDraftTensorName(tensor.Name, baseBlocks, nextn) {
return fmt.Errorf("MTP draft safetensors produced unexpected tensor %q", tensor.Name)
}
tensor.Shape = slices.Clone(tensor.Shape)
slices.Reverse(tensor.Shape)
}
kv := maps.Clone(baseKV)
qwen35RemoveSplitMetadata(kv, arch)
kv[arch+".block_count"] = baseBlocks + nextn
kv[arch+".nextn_predict_layers"] = nextn
tensors := make([]*ggml.Tensor, 0, len(baseTensors)+len(mtpTensors))
tensors = append(tensors, baseTensors...)
tensors = append(tensors, mtpTensors...)
var parameters uint64
for _, tensor := range tensors {
parameters += tensor.Elements()
}
kv["general.parameter_count"] = parameters
return ggml.WriteGGUF(f, kv, tensors)
}
func qwen35RemoveSplitMetadata(kv ggml.KV, arch string) {
for _, key := range []string{
"split.no",
"split.count",
"split.tensors.count",
} {
delete(kv, key)
delete(kv, arch+"."+key)
}
}
func qwen35MTPDraftTensorName(name string, base, nextn uint32) bool {
for i := range nextn {
if strings.HasPrefix(name, fmt.Sprintf("blk.%d.", base+i)) {
return true
}
}
return false
}
func (q *qwen3NextModel) kvHeadCounts() ([]uint32, error) {
if len(q.LayerTypes) > 0 {
kv := make([]uint32, q.NumHiddenLayers)
@@ -259,7 +430,10 @@ func (q *qwen3NextModel) KV(t *Tokenizer) KV {
}
kv["general.architecture"] = arch
kv["tokenizer.ggml.pre"] = "qwen35"
kv["block_count"] = q.NumHiddenLayers
kv["block_count"] = q.NumHiddenLayers + q.NumNextNPredictLayers
if q.NumNextNPredictLayers > 0 {
kv["nextn_predict_layers"] = q.NumNextNPredictLayers
}
kv["context_length"] = q.MaxPositionEmbeddings
kv["embedding_length"] = q.HiddenSize
kv["feed_forward_length"] = q.IntermediateSize
@@ -282,7 +456,11 @@ func (q *qwen3NextModel) KV(t *Tokenizer) KV {
if sections := q.ropeSections(); len(sections) > 0 {
kv["mrope_sections"] = sections
kv["rope.mrope_section"] = sections
kv["rope.dimension_sections"] = sections
dimensionSections := append([]int32(nil), sections...)
if len(dimensionSections) == 3 {
dimensionSections = append(dimensionSections, 0)
}
kv["rope.dimension_sections"] = dimensionSections
}
if q.RopeParameters.MRopeInterleaved {
kv["rope.mrope_interleaved"] = true
@@ -321,12 +499,21 @@ func (q *qwen3NextModel) KV(t *Tokenizer) KV {
}
if headCounts, err := q.kvHeadCounts(); err == nil {
kv["attention.head_count_kv"] = headCounts
var maxKV uint32
for _, count := range headCounts {
if count > maxKV {
maxKV = count
}
}
kv["attention.head_count_kv"] = maxKV
}
if q.VisionModel.Depth > 0 {
kv["vision.block_count"] = q.VisionModel.Depth
kv["vision.embedding_length"] = q.VisionModel.HiddenSize
if q.VisionModel.IntermediateSize > 0 {
kv["vision.feed_forward_length"] = q.VisionModel.IntermediateSize
}
kv["vision.attention.head_count"] = q.VisionModel.NumHeads
kv["vision.num_channels"] = q.VisionModel.InChannels
if q.VisionModel.PatchSize > 0 {
@@ -372,6 +559,378 @@ func (q *qwen3NextModel) KV(t *Tokenizer) KV {
return kv
}
func (q *qwen3NextModel) TextKV(t *Tokenizer) KV {
kv := q.KV(t)
for _, key := range []string{
"vision.block_count",
"vision.embedding_length",
"vision.feed_forward_length",
"vision.attention.head_count",
"vision.num_channels",
"vision.patch_size",
"vision.spatial_merge_size",
"vision.attention.layer_norm_epsilon",
"vision.rope.freq_base",
"vision.temporal_patch_size",
"vision.deepstack_visual_indexes",
"vision.shortest_edge",
"vision.longest_edge",
"vision.image_mean",
"vision.image_std",
"image_token_id",
"vision_start_token_id",
"vision_end_token_id",
"mrope_sections",
"rope.mrope_section",
"rope.mrope_interleaved",
"ssm.v_head_reordered",
} {
delete(kv, key)
}
return kv
}
func (q *qwen3NextModel) ProjectorKV(*Tokenizer) KV {
depth := q.VisionModel.Depth
deepstack := make([]bool, depth)
for _, idx := range q.VisionModel.DeepstackVisualIndexes {
if idx >= 0 && uint32(idx) < depth {
deepstack[idx] = true
}
}
imageSize := uint32(768)
if q.VisionModel.NumPositionEmbeddings > 0 && q.VisionModel.PatchSize > 0 {
root := uint32(math.Sqrt(float64(q.VisionModel.NumPositionEmbeddings)))
if root*root == q.VisionModel.NumPositionEmbeddings {
imageSize = root * q.VisionModel.PatchSize
}
}
projectionDim := q.VisionModel.OutHiddenSize
if projectionDim == 0 {
projectionDim = q.HiddenSize
}
layerNormEps := q.VisionModel.RMSNormEps
if layerNormEps == 0 {
layerNormEps = 1e-6
}
kv := KV{
"general.architecture": "clip",
"general.type": "mmproj",
"general.file_type": uint32(1),
"general.quantization_version": uint32(2),
"clip.has_vision_encoder": true,
"clip.projector_type": "qwen3vl_merger",
"clip.use_gelu": true,
"clip.vision.block_count": depth,
"clip.vision.embedding_length": q.VisionModel.HiddenSize,
"clip.vision.feed_forward_length": q.VisionModel.IntermediateSize,
"clip.vision.attention.head_count": q.VisionModel.NumHeads,
"clip.vision.image_size": imageSize,
"clip.vision.patch_size": q.VisionModel.PatchSize,
"clip.vision.projection_dim": projectionDim,
"clip.vision.spatial_merge_size": q.VisionModel.SpatialMergeSize,
"clip.vision.attention.layer_norm_epsilon": layerNormEps,
"clip.vision.is_deepstack_layers": deepstack,
}
if len(q.VisionModel.ImageMean) > 0 {
kv["clip.vision.image_mean"] = q.VisionModel.ImageMean
}
if len(q.VisionModel.ImageStd) > 0 {
kv["clip.vision.image_std"] = q.VisionModel.ImageStd
}
return kv
}
func (q *qwen3NextModel) TextTensors(ts []Tensor, _ *Tokenizer) []*ggml.Tensor {
var text []Tensor
for _, t := range ts {
if qwen3NextVisionTensor(t.Name()) {
continue
}
text = append(text, t)
}
return q.Tensors(text)
}
func (q *qwen3NextModel) ProjectorTensors(ts []Tensor) []*ggml.Tensor {
if q.VisionModel.Depth == 0 {
return nil
}
rename := strings.NewReplacer(
"v.pos_embed", "v.position_embd",
"v.patch_embed", "v.patch_embd",
"v.merger.norm", "v.post_ln",
"v.merger.linear_fc1", "mm.0",
"v.merger.linear_fc2", "mm.2",
".mlp.linear_fc1", ".ffn_up",
".mlp.linear_fc2", ".ffn_down",
".norm1", ".ln1",
".norm2", ".ln2",
)
var out []*ggml.Tensor
for _, t := range ts {
name := t.Name()
if !qwen3NextVisionTensor(name) {
continue
}
if name == "v.patch_embed.weight" {
out = append(out, q.qwen35PatchEmbedTensors(t)...)
continue
}
outName := rename.Replace(name)
kind := t.Kind()
writer := io.WriterTo(t)
if outName == "v.position_embd.weight" {
kind = tensorKindFP32
writer = tensorFloat32Writer{tensor: t}
} else if sourceDType(t) == "BF16" && kind == tensorKindFP16 {
kind = tensorKindBF16
writer = tensorBF16Writer{tensor: t}
}
out = append(out, &ggml.Tensor{
Name: outName,
Kind: kind,
Shape: slices.Clone(t.Shape()),
WriterTo: writer,
})
}
return out
}
func qwen3NextVisionTensor(name string) bool {
return strings.HasPrefix(name, "v.")
}
func (q *qwen3NextModel) qwen35PatchEmbedTensors(t Tensor) []*ggml.Tensor {
shape := t.Shape()
if len(shape) != 5 || shape[2] != 2 {
return nil
}
outShape := []uint64{shape[0], shape[1], shape[3], shape[4]}
return []*ggml.Tensor{
{
Name: "v.patch_embd.weight",
Kind: tensorKindFP32,
Shape: slices.Clone(outShape),
WriterTo: tensorFloat32Writer{tensor: t, repacker: q.qwen35PatchEmbedSlice(0)},
},
{
Name: "v.patch_embd.weight.1",
Kind: tensorKindFP32,
Shape: slices.Clone(outShape),
WriterTo: tensorFloat32Writer{tensor: t, repacker: q.qwen35PatchEmbedSlice(1)},
},
}
}
func (q *qwen3NextModel) qwen35PatchEmbedSlice(slice int) Repacker {
return func(_ string, data []float32, shape []uint64) ([]float32, error) {
if len(shape) != 5 || shape[2] != 2 {
return nil, fmt.Errorf("qwen3next: unexpected patch_embed shape %v", shape)
}
outChannels := int(shape[0])
inChannels := int(shape[1])
frames := int(shape[2])
height := int(shape[3])
width := int(shape[4])
if slice < 0 || slice >= frames {
return nil, fmt.Errorf("qwen3next: patch_embed slice %d out of range", slice)
}
expected := outChannels * inChannels * frames * height * width
if len(data) != expected {
return nil, fmt.Errorf("qwen3next: patch_embed data size %d, expected %d", len(data), expected)
}
out := make([]float32, outChannels*inChannels*height*width)
for oc := range outChannels {
for ic := range inChannels {
for y := range height {
for x := range width {
src := ((((oc*inChannels+ic)*frames+slice)*height + y) * width) + x
dst := (((oc*inChannels+ic)*height + y) * width) + x
out[dst] = data[src]
}
}
}
}
return out, nil
}
}
type tensorBF16Writer struct {
tensor Tensor
repacker Repacker
}
func (w tensorBF16Writer) WriteTo(dst io.Writer) (int64, error) {
data, err := tensorFloat32Data(w.tensor)
if err != nil {
return 0, err
}
if w.repacker != nil {
data, err = w.repacker(w.tensor.Name(), data, w.tensor.Shape())
if err != nil {
return 0, err
}
}
u8s := bfloat16.EncodeFloat32(data)
if _, err := dst.Write(u8s); err != nil {
return 0, err
}
return int64(len(u8s)), nil
}
type tensorFloat32Writer struct {
tensor Tensor
repacker Repacker
}
func (w tensorFloat32Writer) WriteTo(dst io.Writer) (int64, error) {
data, err := tensorFloat32Data(w.tensor)
if err != nil {
return 0, err
}
if w.repacker != nil {
data, err = w.repacker(w.tensor.Name(), data, w.tensor.Shape())
if err != nil {
return 0, err
}
}
if err := binary.Write(dst, binary.LittleEndian, data); err != nil {
return 0, err
}
return int64(len(data) * 4), nil
}
func tensorFloat32Data(t Tensor) ([]float32, error) {
if st, ok := tensorSafetensor(t); ok {
return safetensorFloat32Data(st)
}
var buf bytes.Buffer
if _, err := t.WriteTo(&buf); err != nil {
return nil, err
}
switch t.Kind() {
case tensorKindFP32:
out := make([]float32, buf.Len()/4)
if err := binary.Read(bytes.NewReader(buf.Bytes()), binary.LittleEndian, out); err != nil {
return nil, err
}
return out, nil
case tensorKindFP16:
raw := make([]uint16, buf.Len()/2)
if err := binary.Read(bytes.NewReader(buf.Bytes()), binary.LittleEndian, raw); err != nil {
return nil, err
}
out := make([]float32, len(raw))
for i, v := range raw {
out[i] = float16.Frombits(v).Float32()
}
return out, nil
case tensorKindBF16:
return bfloat16.DecodeFloat32(buf.Bytes()), nil
default:
return nil, fmt.Errorf("unsupported tensor kind %d for F32 writer", t.Kind())
}
}
func tensorSafetensor(t Tensor) (safetensor, bool) {
switch t := t.(type) {
case safetensor:
return t, true
case *safetensor:
return *t, true
default:
return safetensor{}, false
}
}
func safetensorFloat32Data(st safetensor) ([]float32, error) {
f, err := st.fs.Open(st.path)
if err != nil {
return nil, err
}
defer f.Close()
var r io.Reader
if readerAt, ok := f.(io.ReaderAt); ok {
r = io.NewSectionReader(readerAt, st.offset, st.size)
} else if seeker, ok := f.(io.Seeker); ok {
if _, err := seeker.Seek(st.offset, io.SeekStart); err != nil {
return nil, err
}
r = f
} else {
if _, err := io.CopyN(io.Discard, f, st.offset); err != nil {
return nil, err
}
r = f
}
br := bufio.NewReaderSize(r, min(32<<10, int(st.size)))
var out []float32
switch st.dtype {
case "F32":
out = make([]float32, st.size/4)
if err := binary.Read(br, binary.LittleEndian, out); err != nil {
return nil, err
}
case "F16":
raw := make([]uint16, st.size/2)
if err := binary.Read(br, binary.LittleEndian, raw); err != nil {
return nil, err
}
out = make([]float32, len(raw))
for i, v := range raw {
out[i] = float16.Frombits(v).Float32()
}
case "BF16":
raw := make([]uint8, st.size)
if err := binary.Read(br, binary.LittleEndian, raw); err != nil {
return nil, err
}
out = bfloat16.DecodeFloat32(raw)
case "F8_E4M3":
raw := make([]uint8, st.size)
if err := binary.Read(br, binary.LittleEndian, raw); err != nil {
return nil, err
}
out, err = st.decodeFP8E4M3(raw)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unsupported safetensor dtype %q", st.dtype)
}
if st.repacker != nil {
out, err = st.repacker(st.Name(), out, st.Shape())
if err != nil {
return nil, err
}
}
return out, nil
}
func (q *qwen3NextModel) Tensors(ts []Tensor) []*ggml.Tensor {
var out []*ggml.Tensor
@@ -398,6 +957,13 @@ func (q *qwen3NextModel) Tensors(ts []Tensor) []*ggml.Tensor {
name := t.Name()
shape := t.Shape()
if names := q.mtpTensorNames(name); len(names) > 0 {
for _, name := range names {
out = q.appendDirectTensor(out, t, name)
}
continue
}
if strings.HasSuffix(name, ".ssm_in.weight") {
if qkv, gate, ok := q.splitQKVZTensor(t); ok {
out = append(out, qkv, gate)
@@ -464,7 +1030,7 @@ func (q *qwen3NextModel) Tensors(ts []Tensor) []*ggml.Tensor {
}
out = append(out, &ggml.Tensor{Name: name, Kind: t.Kind(), Shape: slices.Clone(shape), WriterTo: t})
case strings.HasSuffix(name, ".ssm_dt"):
case strings.HasSuffix(name, ".ssm_dt"), strings.HasSuffix(name, ".ssm_dt.bias"):
if q.shouldReorderVHeads() {
t.SetRepacker(q.repackReorderDim(0, 1))
}
@@ -499,6 +1065,73 @@ func (q *qwen3NextModel) Tensors(ts []Tensor) []*ggml.Tensor {
return out
}
func (q *qwen3NextModel) appendDirectTensor(out []*ggml.Tensor, t Tensor, name string) []*ggml.Tensor {
if qwen3NextShouldShiftNorm(name) {
t = t.Clone()
t.SetRepacker(q.addOne)
}
return append(out, &ggml.Tensor{Name: name, Kind: t.Kind(), Shape: slices.Clone(t.Shape()), WriterTo: t})
}
func qwen3NextShouldShiftNorm(name string) bool {
if strings.HasSuffix(name, ".ssm_norm.weight") {
return false
}
return strings.HasSuffix(name, "_norm.weight") ||
strings.HasSuffix(name, ".nextn.enorm.weight") ||
strings.HasSuffix(name, ".nextn.hnorm.weight")
}
func (q *qwen3NextModel) mtpTensorNames(name string) []string {
if !strings.HasPrefix(name, "mtp.") {
return nil
}
base := q.NumHiddenLayers
nextn := q.NumNextNPredictLayers
if nextn == 0 {
nextn = 1
}
if rest := strings.TrimPrefix(name, "mtp.layers."); rest != name {
layer, suffix, ok := strings.Cut(rest, ".")
if !ok {
return nil
}
idx, err := strconv.ParseUint(layer, 10, 32)
if err != nil {
return nil
}
return []string{fmt.Sprintf("blk.%d.%s", base+uint32(idx), suffix)}
}
var suffix string
switch name {
case "mtp.fc.weight":
suffix = "nextn.eh_proj.weight"
case "mtp.pre_fc_norm_embedding.weight":
suffix = "nextn.enorm.weight"
case "mtp.pre_fc_norm_hidden.weight":
suffix = "nextn.hnorm.weight"
case "mtp.norm.weight":
suffix = "nextn.shared_head_norm.weight"
case "mtp.embed_tokens.weight":
suffix = "nextn.embed_tokens.weight"
case "mtp.shared_head.head.weight":
suffix = "nextn.shared_head_head.weight"
case "mtp.shared_head.norm.weight":
suffix = "nextn.shared_head_norm.weight"
default:
return nil
}
names := make([]string, 0, nextn)
for i := range nextn {
names = append(names, fmt.Sprintf("blk.%d.%s", base+i, suffix))
}
return names
}
func (q *qwen3NextModel) repackReorderDim(dim, headDim int) Repacker {
return func(_ string, data []float32, shape []uint64) ([]float32, error) {
if !q.shouldReorderVHeads() {
@@ -925,7 +1558,7 @@ func (q *qwen3NextModel) Replacements() []string {
"linear_attn.in_proj_b", "ssm_beta",
"linear_attn.conv1d", "ssm_conv1d",
"linear_attn.dt_bias", "ssm_dt",
"linear_attn.dt_bias", "ssm_dt.bias",
"linear_attn.dt_proj", "ssm_dt",
"linear_attn.A_log", "ssm_a",
"linear_attn.norm", "ssm_norm",
+287 -11
View File
@@ -4,10 +4,12 @@ import (
"bytes"
"encoding/binary"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"github.com/d4l3k/go-bfloat16"
"github.com/ollama/ollama/fs/ggml"
)
@@ -106,11 +108,7 @@ func TestQwen3NextKVLegacyConfig(t *testing.T) {
t.Fatalf("unexpected tokenizer pre: got %v want %v", got, want)
}
headCountKV, ok := kv["attention.head_count_kv"].([]uint32)
if !ok {
t.Fatalf("attention.head_count_kv has unexpected type: %T", kv["attention.head_count_kv"])
}
if got, want := headCountKV, []uint32{0, 2, 0, 2}; !slices.Equal(got, want) {
if got, want := kv["attention.head_count_kv"], uint32(2); got != want {
t.Fatalf("unexpected attention.head_count_kv: got %v want %v", got, want)
}
@@ -198,6 +196,7 @@ func TestQwen35KVFromTextConfig(t *testing.T) {
VisionModel: qwen3NextVisionConfig{
Depth: 2,
HiddenSize: 128,
IntermediateSize: 512,
NumHeads: 4,
InChannels: 3,
PatchSize: 16,
@@ -225,11 +224,7 @@ func TestQwen35KVFromTextConfig(t *testing.T) {
t.Fatalf("unexpected architecture: got %v want %v", got, want)
}
headCountKV, ok := kv["attention.head_count_kv"].([]uint32)
if !ok {
t.Fatalf("attention.head_count_kv has unexpected type: %T", kv["attention.head_count_kv"])
}
if got, want := headCountKV, []uint32{0, 4, 0, 4}; !slices.Equal(got, want) {
if got, want := kv["attention.head_count_kv"], uint32(4); got != want {
t.Fatalf("unexpected attention.head_count_kv: got %v want %v", got, want)
}
@@ -248,7 +243,7 @@ func TestQwen35KVFromTextConfig(t *testing.T) {
if !ok {
t.Fatalf("rope.dimension_sections has unexpected type: %T", kv["rope.dimension_sections"])
}
if got, want := ropeSections, []int32{11, 11, 10}; !slices.Equal(got, want) {
if got, want := ropeSections, []int32{11, 11, 10, 0}; !slices.Equal(got, want) {
t.Fatalf("unexpected rope.dimension_sections: got %v want %v", got, want)
}
@@ -259,6 +254,254 @@ func TestQwen35KVFromTextConfig(t *testing.T) {
if got, want := kv["vision.block_count"], uint32(2); got != want {
t.Fatalf("unexpected vision.block_count: got %v want %v", got, want)
}
if got, want := kv["vision.feed_forward_length"], uint32(512); got != want {
t.Fatalf("unexpected vision.feed_forward_length: got %v want %v", got, want)
}
}
func TestQwen35MTPTensors(t *testing.T) {
m := &qwen3NextModel{
ModelParameters: ModelParameters{
ModelType: "qwen3_5",
},
qwen3NextTextConfig: qwen3NextTextConfig{
NumHiddenLayers: 32,
NumNextNPredictLayers: 1,
},
}
kv := m.KV(&Tokenizer{Vocabulary: &Vocabulary{}})
if got, want := kv["block_count"], uint32(33); got != want {
t.Fatalf("unexpected block_count: got %v want %v", got, want)
}
if got, want := kv["nextn_predict_layers"], uint32(1); got != want {
t.Fatalf("unexpected nextn_predict_layers: got %v want %v", got, want)
}
tensors := m.Tensors([]Tensor{
&fakeTensor{name: "mtp.fc.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
&fakeTensor{name: "mtp.pre_fc_norm_embedding.weight", shape: []uint64{2}, data: []float32{0, 1}},
&fakeTensor{name: "mtp.pre_fc_norm_hidden.weight", shape: []uint64{2}, data: []float32{0, 1}},
&fakeTensor{name: "mtp.norm.weight", shape: []uint64{2}, data: []float32{0, 1}},
&fakeTensor{name: "mtp.layers.0.attn_q.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
&fakeTensor{name: "mtp.layers.0.ffn_down.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
})
byName := map[string]*ggml.Tensor{}
for _, tensor := range tensors {
byName[tensor.Name] = tensor
}
for _, name := range []string{
"blk.32.nextn.eh_proj.weight",
"blk.32.nextn.enorm.weight",
"blk.32.nextn.hnorm.weight",
"blk.32.nextn.shared_head_norm.weight",
"blk.32.attn_q.weight",
"blk.32.ffn_down.weight",
} {
if _, ok := byName[name]; !ok {
t.Fatalf("missing MTP tensor %q", name)
}
}
for _, name := range []string{
"blk.32.nextn.enorm.weight",
"blk.32.nextn.hnorm.weight",
"blk.32.nextn.shared_head_norm.weight",
} {
if got, want := readTensorData(t, byName[name]), []float32{1, 2}; !slices.Equal(got, want) {
t.Fatalf("unexpected shifted norm values for %s: got %v want %v", name, got, want)
}
}
}
func TestQwen35NativeSplitKV(t *testing.T) {
m := &qwen3NextModel{
ModelParameters: ModelParameters{
ModelType: "qwen3_5",
},
TextConfig: &qwen3NextTextConfig{
MaxPositionEmbeddings: 16384,
HiddenSize: 2560,
NumHiddenLayers: 4,
IntermediateSize: 9216,
NumAttentionHeads: 16,
NumKeyValueHeads: 4,
HeadDim: 256,
RMSNormEPS: 1e-6,
FullAttentionInterval: 2,
LinearConvKernelDim: 4,
LinearKeyHeadDim: 128,
LinearNumKeyHeads: 16,
LinearNumValueHeads: 32,
LinearValueHeadDim: 128,
RopeParameters: qwen3NextRopeParams{
MRopeInterleaved: true,
MropeSection: []int32{11, 11, 10},
RopeTheta: 10_000_000,
PartialRotaryFactor: 0.25,
},
},
VisionModel: qwen3NextVisionConfig{
Depth: 24,
HiddenSize: 1024,
IntermediateSize: 4096,
NumHeads: 16,
NumPositionEmbeddings: 2304,
InChannels: 3,
OutHiddenSize: 2560,
PatchSize: 16,
SpatialMergeSize: 2,
},
ImageTokenID: 248056,
VisionStartTokenID: 248053,
VisionEndTokenID: 248054,
}
m.VisionModel.ImageMean = []float32{0.5, 0.5, 0.5}
m.VisionModel.ImageStd = []float32{0.5, 0.5, 0.5}
if err := m.parseMore(os.DirFS(t.TempDir())); err != nil {
t.Fatal(err)
}
textKV := m.TextKV(&Tokenizer{Vocabulary: &Vocabulary{}})
for _, key := range []string{
"vision.block_count",
"image_token_id",
"vision_start_token_id",
"vision_end_token_id",
"mrope_sections",
"rope.mrope_section",
"rope.mrope_interleaved",
"ssm.v_head_reordered",
} {
if _, ok := textKV[key]; ok {
t.Fatalf("TextKV retained %q", key)
}
}
if got, want := textKV["rope.dimension_sections"], []int32{11, 11, 10, 0}; !slices.Equal(got.([]int32), want) {
t.Fatalf("unexpected rope.dimension_sections: got %v want %v", got, want)
}
projectorKV := m.ProjectorKV(&Tokenizer{Vocabulary: &Vocabulary{}})
if got, want := projectorKV["general.architecture"], "clip"; got != want {
t.Fatalf("unexpected projector architecture: got %v want %v", got, want)
}
if got, want := projectorKV["clip.projector_type"], "qwen3vl_merger"; got != want {
t.Fatalf("unexpected projector type: got %v want %v", got, want)
}
if got, want := projectorKV["clip.vision.feed_forward_length"], uint32(4096); got != want {
t.Fatalf("unexpected projector feed_forward_length: got %v want %v", got, want)
}
if got, want := projectorKV["clip.vision.image_size"], uint32(768); got != want {
t.Fatalf("unexpected projector image_size: got %v want %v", got, want)
}
if got, want := projectorKV["clip.vision.projection_dim"], uint32(2560); got != want {
t.Fatalf("unexpected projector projection_dim: got %v want %v", got, want)
}
}
func TestQwen35ProjectorTensors(t *testing.T) {
m := &qwen3NextModel{
VisionModel: qwen3NextVisionConfig{Depth: 1},
}
patch := &fakeTensor{
name: "v.patch_embed.weight",
shape: []uint64{2, 2, 2, 1, 2},
data: []float32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
}
tensors := m.ProjectorTensors([]Tensor{
patch,
&fakeTensor{name: "v.pos_embed.weight", shape: []uint64{4, 2}, data: []float32{0, 1, 2, 3, 4, 5, 6, 7}},
&fakeTensor{name: "v.blk.0.attn_qkv.weight", shape: []uint64{6, 2}, data: make([]float32, 12), sourceDType: "BF16", kind: tensorKindFP16},
&fakeTensor{name: "v.blk.0.mlp.linear_fc1.weight", shape: []uint64{8, 2}, data: make([]float32, 16), sourceDType: "BF16", kind: tensorKindFP16},
&fakeTensor{name: "token_embd.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
&fakeTensor{name: "mtp.fc.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
})
byName := map[string]*ggml.Tensor{}
for _, tensor := range tensors {
byName[tensor.Name] = tensor
}
if _, ok := byName["token_embd.weight"]; ok {
t.Fatalf("projector tensors included text tensor")
}
if _, ok := byName["mtp.fc.weight"]; ok {
t.Fatalf("projector tensors included MTP tensor")
}
if got := byName["v.position_embd.weight"]; got == nil || got.Kind != tensorKindFP32 {
t.Fatalf("position embedding was not promoted to F32: %#v", got)
}
if got := byName["v.blk.0.attn_qkv.weight"]; got == nil {
t.Fatalf("attn_qkv tensor missing")
} else if got.Kind != tensorKindBF16 {
t.Fatalf("attn_qkv tensor was not preserved as BF16: %#v", got)
}
if got := byName["v.blk.0.ffn_up.weight"]; got == nil {
t.Fatalf("ffn_up tensor missing")
} else if got.Kind != tensorKindBF16 {
t.Fatalf("ffn_up tensor was not preserved as BF16: %#v", got)
}
first := byName["v.patch_embd.weight"]
if first == nil {
t.Fatalf("first patch embedding slice missing")
}
if got, want := first.Shape, []uint64{2, 2, 1, 2}; !slices.Equal(got, want) {
t.Fatalf("unexpected first patch shape: got %v want %v", got, want)
}
if got, want := readTensorData(t, first), []float32{0, 1, 4, 5, 8, 9, 12, 13}; !slices.Equal(got, want) {
t.Fatalf("unexpected first patch data: got %v want %v", got, want)
}
second := byName["v.patch_embd.weight.1"]
if second == nil {
t.Fatalf("second patch embedding slice missing")
}
if got, want := readTensorData(t, second), []float32{2, 3, 6, 7, 10, 11, 14, 15}; !slices.Equal(got, want) {
t.Fatalf("unexpected second patch data: got %v want %v", got, want)
}
}
func TestQwen35BF16ProjectorWriterPreservesSource(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "tensor.bin")
values := []float32{1, -2, 3.5, 4.25}
raw := bfloat16.EncodeFloat32(values)
if err := os.WriteFile(path, raw, 0o644); err != nil {
t.Fatal(err)
}
st := safetensor{
fs: os.DirFS(dir),
path: "tensor.bin",
dtype: "BF16",
offset: 0,
size: int64(len(raw)),
tensorBase: &tensorBase{
name: "v.blk.0.attn_qkv.weight",
shape: []uint64{2, 2},
},
}
tensor := &ggml.Tensor{
Name: "v.blk.0.attn_qkv.weight",
Kind: tensorKindBF16,
Shape: []uint64{2, 2},
WriterTo: tensorBF16Writer{tensor: st},
}
var got bytes.Buffer
if n, err := tensor.WriteTo(&got); err != nil {
t.Fatal(err)
} else if n != int64(len(raw)) {
t.Fatalf("unexpected byte count: got %d want %d", n, len(raw))
}
if !bytes.Equal(got.Bytes(), raw) {
t.Fatalf("BF16 writer changed source bytes: got %x want %x", got.Bytes(), raw)
}
}
func TestQwen3NextReplacements(t *testing.T) {
@@ -273,6 +516,12 @@ func TestQwen3NextReplacements(t *testing.T) {
if got, want := r.Replace("model.layers.1.linear_attn.in_proj_qkvz.weight"), "blk.1.ssm_in.weight"; got != want {
t.Fatalf("unexpected legacy replacement: got %q want %q", got, want)
}
if got, want := r.Replace("model.layers.1.linear_attn.dt_bias"), "blk.1.ssm_dt.bias"; got != want {
t.Fatalf("unexpected dt bias replacement: got %q want %q", got, want)
}
if got, want := r.Replace("model.layers.1.linear_attn.dt_proj.weight"), "blk.1.ssm_dt.weight"; got != want {
t.Fatalf("unexpected dt projection replacement: got %q want %q", got, want)
}
}
func TestQwen35ReordersVHeads(t *testing.T) {
@@ -399,6 +648,33 @@ func TestQwen35ReordersSsmBetaRows(t *testing.T) {
}
}
func TestQwen35ReordersSsmDtBias(t *testing.T) {
m := &qwen3NextModel{
ModelParameters: ModelParameters{
ModelType: "qwen3_5",
},
qwen3NextTextConfig: qwen3NextTextConfig{
LinearNumKeyHeads: 2,
LinearNumValueHeads: 4,
},
}
out := m.Tensors([]Tensor{
&fakeTensor{
name: "blk.0.ssm_dt.bias",
shape: []uint64{4},
data: []float32{0, 1, 2, 3},
},
})
if len(out) != 1 {
t.Fatalf("unexpected output tensor count: got %d want 1", len(out))
}
if got, want := readTensorData(t, out[0]), []float32{0, 2, 1, 3}; !slices.Equal(got, want) {
t.Fatalf("unexpected ssm_dt.bias data: got %v want %v", got, want)
}
}
func TestQwen35ReordersConv1DChannelDim(t *testing.T) {
m := &qwen3NextModel{
ModelParameters: ModelParameters{
+257 -2
View File
@@ -3,8 +3,13 @@ package convert
import (
"cmp"
"encoding/json"
"fmt"
"io"
"io/fs"
"math"
"regexp"
"slices"
"strconv"
"strings"
"github.com/ollama/ollama/fs/ggml"
@@ -25,6 +30,9 @@ type qwen3VLModel struct {
RopeTheta float32 `json:"rope_theta"`
TemporalPatchSize uint32 `json:"temporal_patch_size"`
DeepstackVisualIndexes []int32 `json:"deepstack_visual_indexes"`
IntermediateSize uint32 `json:"intermediate_size"`
OutHiddenSize uint32 `json:"out_hidden_size"`
NumPositionEmbeddings uint32 `json:"num_position_embeddings"`
Size struct {
ShortestEdge uint32 `json:"shortest_edge"`
@@ -36,6 +44,8 @@ type qwen3VLModel struct {
} `json:"vision_config"`
}
var _ MultimodalConverter = (*qwen3VLModel)(nil)
func (m *qwen3VLModel) parseMore(fsys fs.FS) error {
bts, err := fs.ReadFile(fsys, "preprocessor_config.json")
if err != nil {
@@ -55,8 +65,20 @@ func (m *qwen3VLModel) KV(t *Tokenizer) KV {
// override architecture
kv["general.architecture"] = arch
if sections := m.RopeScaling.MropeSection; len(sections) > 0 {
dimensionSections := append([]int32(nil), sections...)
if len(dimensionSections) == 3 {
dimensionSections = append(dimensionSections, 0)
}
kv["rope.dimension_sections"] = dimensionSections
}
kv["n_deepstack_layers"] = uint32(len(m.VisionModel.DeepstackVisualIndexes))
kv["vision.block_count"] = cmp.Or(m.VisionModel.Depth, 32)
kv["vision.embedding_length"] = m.VisionModel.HiddenSize
if m.VisionModel.IntermediateSize > 0 {
kv["vision.feed_forward_length"] = m.VisionModel.IntermediateSize
}
kv["vision.attention.head_count"] = cmp.Or(m.VisionModel.NumHeads, 16)
kv["vision.num_channels"] = m.VisionModel.InChannels
kv["vision.patch_size"] = cmp.Or(m.VisionModel.PatchSize, 14)
@@ -75,6 +97,234 @@ func (m *qwen3VLModel) KV(t *Tokenizer) KV {
return kv
}
func (m *qwen3VLModel) TextKV(t *Tokenizer) KV {
kv := m.KV(t)
for _, key := range []string{
"vision.block_count",
"vision.embedding_length",
"vision.feed_forward_length",
"vision.attention.head_count",
"vision.num_channels",
"vision.patch_size",
"vision.spatial_merge_size",
"vision.attention.layer_norm_epsilon",
"vision.rope.freq_base",
"vision.temporal_patch_size",
"vision.deepstack_visual_indexes",
"vision.shortest_edge",
"vision.longest_edge",
"vision.image_mean",
"vision.image_std",
"rope.mrope_section",
} {
delete(kv, key)
}
return kv
}
func (m *qwen3VLModel) ProjectorKV(*Tokenizer) KV {
depth := cmp.Or(m.VisionModel.Depth, uint32(32))
deepstack := make([]bool, depth)
for _, idx := range m.VisionModel.DeepstackVisualIndexes {
if idx >= 0 && uint32(idx) < depth {
deepstack[idx] = true
}
}
projectionDim := m.VisionModel.OutHiddenSize
if projectionDim == 0 {
projectionDim = m.HiddenSize
}
layerNormEps := m.VisionModel.RMSNormEps
if layerNormEps == 0 {
layerNormEps = 1e-6
}
kv := KV{
"general.architecture": "clip",
"general.type": "mmproj",
"general.file_type": uint32(1),
"general.quantization_version": uint32(2),
"clip.has_vision_encoder": true,
"clip.projector_type": "qwen3vl_merger",
"clip.use_gelu": true,
"clip.vision.block_count": depth,
"clip.vision.embedding_length": m.VisionModel.HiddenSize,
"clip.vision.feed_forward_length": cmp.Or(m.VisionModel.IntermediateSize, m.VisionModel.HiddenSize*4),
"clip.vision.attention.head_count": cmp.Or(m.VisionModel.NumHeads, uint32(16)),
"clip.vision.attention.layer_norm_epsilon": layerNormEps,
"clip.vision.num_channels": m.VisionModel.InChannels,
"clip.vision.patch_size": cmp.Or(m.VisionModel.PatchSize, uint32(14)),
"clip.vision.spatial_merge_size": cmp.Or(m.VisionModel.SpatialMergeSize, uint32(2)),
"clip.vision.image_size": m.projectorImageSize(),
"clip.vision.projection_dim": projectionDim,
"clip.vision.temporal_patch_size": cmp.Or(m.VisionModel.TemporalPatchSize, uint32(2)),
"clip.vision.rope.freq_base": cmp.Or(m.VisionModel.RopeTheta, float32(1e4)),
"clip.vision.is_deepstack_layers": deepstack,
}
if m.VisionModel.Size.ShortestEdge > 0 {
kv["clip.vision.image_min_pixels"] = m.VisionModel.Size.ShortestEdge
}
if m.VisionModel.Size.LongestEdge > 0 {
kv["clip.vision.image_max_pixels"] = m.VisionModel.Size.LongestEdge
}
if len(m.VisionModel.ImageMean) == 3 {
kv["clip.vision.image_mean"] = m.VisionModel.ImageMean
}
if len(m.VisionModel.ImageStd) == 3 {
kv["clip.vision.image_std"] = m.VisionModel.ImageStd
}
return kv
}
func (m *qwen3VLModel) projectorImageSize() uint32 {
if m.VisionModel.NumPositionEmbeddings > 0 && m.VisionModel.PatchSize > 0 {
root := uint32(math.Sqrt(float64(m.VisionModel.NumPositionEmbeddings)))
if root*root == m.VisionModel.NumPositionEmbeddings {
return root * m.VisionModel.PatchSize
}
}
return uint32(768)
}
func qwen3VLVisionTensor(name string) bool {
return strings.HasPrefix(name, "v.") || strings.HasPrefix(name, "mm.")
}
func (m *qwen3VLModel) TextTensors(ts []Tensor, _ *Tokenizer) []*ggml.Tensor {
var textOnly []Tensor
for _, t := range ts {
if qwen3VLVisionTensor(t.Name()) {
continue
}
textOnly = append(textOnly, t)
}
return m.qwen3Model.Tensors(textOnly)
}
func (m *qwen3VLModel) qwen3VLProjectorRename(name string) string {
if strings.HasPrefix(name, "v.merger.") {
name = strings.Replace(name, "v.merger.linear_fc1", "mm.0", 1)
name = strings.Replace(name, "v.merger.linear_fc2", "mm.2", 1)
name = strings.Replace(name, "v.merger.norm", "v.post_ln", 1)
return name
}
if strings.HasPrefix(name, "v.deepstack.") {
re := regexp.MustCompile(`^v\.deepstack\.(\d+)\.(.+)$`)
if matches := re.FindStringSubmatch(name); matches != nil {
seqIdx, err := strconv.Atoi(matches[1])
if err == nil && seqIdx < len(m.VisionModel.DeepstackVisualIndexes) {
blockIdx := m.VisionModel.DeepstackVisualIndexes[seqIdx]
suffix := matches[2]
suffix = strings.Replace(suffix, "linear_fc1", "fc1", 1)
suffix = strings.Replace(suffix, "linear_fc2", "fc2", 1)
return fmt.Sprintf("v.deepstack.%d.%s", blockIdx, suffix)
}
}
}
return name
}
func (m *qwen3VLModel) ProjectorTensors(ts []Tensor) []*ggml.Tensor {
var out []*ggml.Tensor
for _, t := range ts {
if !qwen3VLVisionTensor(t.Name()) {
continue
}
name := m.qwen3VLProjectorRename(t.Name())
if name == "v.patch_embd.weight" {
out = append(out, m.qwen3VLPatchEmbedTensors(t)...)
continue
}
kind := t.Kind()
var writer io.WriterTo = t
if name == "v.position_embd.weight" {
kind = tensorKindFP32
writer = tensorFloat32Writer{tensor: t}
} else if sourceDType(t) == "BF16" && kind == tensorKindFP16 {
kind = tensorKindBF16
writer = tensorBF16Writer{tensor: t}
}
out = append(out, &ggml.Tensor{
Name: name,
Kind: kind,
Shape: slices.Clone(t.Shape()),
WriterTo: writer,
})
}
return out
}
func (m *qwen3VLModel) qwen3VLPatchEmbedTensors(t Tensor) []*ggml.Tensor {
shape := t.Shape()
if len(shape) != 5 || shape[2] != 2 {
return nil
}
outShape := []uint64{shape[0], shape[1], shape[3], shape[4]}
return []*ggml.Tensor{
{
Name: "v.patch_embd.weight",
Kind: tensorKindFP32,
Shape: slices.Clone(outShape),
WriterTo: tensorFloat32Writer{tensor: t, repacker: qwenTemporalPatchEmbedSlice(0)},
},
{
Name: "v.patch_embd.weight.1",
Kind: tensorKindFP32,
Shape: slices.Clone(outShape),
WriterTo: tensorFloat32Writer{tensor: t, repacker: qwenTemporalPatchEmbedSlice(1)},
},
}
}
func qwenTemporalPatchEmbedSlice(slice int) Repacker {
return func(_ string, data []float32, shape []uint64) ([]float32, error) {
if len(shape) != 5 || shape[2] != 2 {
return nil, fmt.Errorf("qwen temporal patch embedding shape %v", shape)
}
outChannels := int(shape[0])
inChannels := int(shape[1])
frames := int(shape[2])
height := int(shape[3])
width := int(shape[4])
if slice < 0 || slice >= frames {
return nil, fmt.Errorf("qwen temporal patch embedding slice %d out of range", slice)
}
expected := outChannels * inChannels * frames * height * width
if len(data) != expected {
return nil, fmt.Errorf("qwen temporal patch embedding data size %d, expected %d", len(data), expected)
}
out := make([]float32, outChannels*inChannels*height*width)
for oc := range outChannels {
for ic := range inChannels {
for y := range height {
for x := range width {
src := ((((oc*inChannels+ic)*frames+slice)*height + y) * width) + x
dst := (((oc*inChannels+ic)*height + y) * width) + x
out[dst] = data[src]
}
}
}
}
return out, nil
}
}
func (m *qwen3VLModel) Tensors(ts []Tensor) []*ggml.Tensor {
var rest []Tensor
var out []*ggml.Tensor
@@ -107,10 +357,15 @@ func (m *qwen3VLModel) Replacements() []string {
m.qwen3Model.Replacements(),
"model.language_", "",
"model.visual", "v",
"patch_embed.proj", "patch_embed",
"patch_embed.proj", "patch_embd",
"pos_embed", "position_embd",
"blocks", "blk",
"attn.qkv", "attn_qkv",
"attn.proj", "attn_out",
"deepstack_merger_list", "deepstack_merger",
"norm1", "ln1",
"norm2", "ln2",
"mlp.linear_fc1", "ffn_up",
"mlp.linear_fc2", "ffn_down",
"deepstack_merger_list", "deepstack",
)
}
+147
View File
@@ -0,0 +1,147 @@
package convert
import (
"slices"
"testing"
"github.com/ollama/ollama/fs/ggml"
)
func TestQwen3VLTextAndProjectorKV(t *testing.T) {
m := &qwen3VLModel{
qwen3Model: qwen3Model{
HiddenSize: 2048,
},
}
m.RopeScaling.Type = "mrope"
m.RopeScaling.MropeSection = []int32{24, 20, 20}
m.VisionModel.Depth = 24
m.VisionModel.HiddenSize = 1024
m.VisionModel.IntermediateSize = 4096
m.VisionModel.OutHiddenSize = 2048
m.VisionModel.NumHeads = 16
m.VisionModel.InChannels = 3
m.VisionModel.PatchSize = 16
m.VisionModel.SpatialMergeSize = 2
m.VisionModel.NumPositionEmbeddings = 2304
m.VisionModel.TemporalPatchSize = 2
m.VisionModel.RMSNormEps = 1e-6
m.VisionModel.RopeTheta = 10000
m.VisionModel.DeepstackVisualIndexes = []int32{5, 11, 17}
m.VisionModel.ImageMean = []float32{0.5, 0.5, 0.5}
m.VisionModel.ImageStd = []float32{0.5, 0.5, 0.5}
textKV := m.TextKV(&Tokenizer{Vocabulary: &Vocabulary{}})
if got, want := textKV["general.architecture"], "qwen3vl"; got != want {
t.Fatalf("unexpected text architecture: got %v want %v", got, want)
}
if got, want := textKV["rope.dimension_sections"], []int32{24, 20, 20, 0}; !slices.Equal(got.([]int32), want) {
t.Fatalf("unexpected rope.dimension_sections: got %v want %v", got, want)
}
if got, want := textKV["n_deepstack_layers"], uint32(3); got != want {
t.Fatalf("unexpected n_deepstack_layers: got %v want %v", got, want)
}
for _, key := range []string{"vision.block_count", "vision.deepstack_visual_indexes", "rope.mrope_section"} {
if _, ok := textKV[key]; ok {
t.Fatalf("TextKV retained %q", key)
}
}
projectorKV := m.ProjectorKV(&Tokenizer{Vocabulary: &Vocabulary{}})
if got, want := projectorKV["general.architecture"], "clip"; got != want {
t.Fatalf("unexpected projector architecture: got %v want %v", got, want)
}
if got, want := projectorKV["general.type"], "mmproj"; got != want {
t.Fatalf("unexpected projector type: got %v want %v", got, want)
}
if got, want := projectorKV["clip.projector_type"], "qwen3vl_merger"; got != want {
t.Fatalf("unexpected projector type: got %v want %v", got, want)
}
if got, want := projectorKV["clip.vision.feed_forward_length"], uint32(4096); got != want {
t.Fatalf("unexpected feed_forward_length: got %v want %v", got, want)
}
if got, want := projectorKV["clip.vision.image_size"], uint32(768); got != want {
t.Fatalf("unexpected image_size: got %v want %v", got, want)
}
mask, ok := projectorKV["clip.vision.is_deepstack_layers"].([]bool)
if !ok {
t.Fatalf("deepstack mask has unexpected type: %T", projectorKV["clip.vision.is_deepstack_layers"])
}
if len(mask) != 24 || !mask[5] || !mask[11] || !mask[17] {
t.Fatalf("unexpected deepstack mask: %v", mask)
}
}
func TestQwen3VLProjectorTensors(t *testing.T) {
m := &qwen3VLModel{}
m.VisionModel.DeepstackVisualIndexes = []int32{5, 11, 17}
tensors := m.ProjectorTensors([]Tensor{
&fakeTensor{
name: "v.patch_embd.weight",
shape: []uint64{2, 2, 2, 1, 2},
data: []float32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
},
&fakeTensor{name: "v.position_embd.weight", shape: []uint64{4, 2}, data: []float32{0, 1, 2, 3, 4, 5, 6, 7}},
&fakeTensor{name: "v.merger.linear_fc1.weight", shape: []uint64{4, 2}, data: make([]float32, 8)},
&fakeTensor{name: "v.merger.linear_fc2.bias", shape: []uint64{4}, data: make([]float32, 4)},
&fakeTensor{name: "v.merger.norm.weight", shape: []uint64{2}, data: make([]float32, 2)},
&fakeTensor{name: "v.deepstack.0.linear_fc1.weight", shape: []uint64{4, 2}, data: make([]float32, 8)},
&fakeTensor{name: "v.deepstack.1.norm.bias", shape: []uint64{2}, data: make([]float32, 2)},
&fakeTensor{name: "v.blk.0.attn_qkv.weight", shape: []uint64{6, 2}, data: make([]float32, 12), sourceDType: "BF16", kind: tensorKindFP16},
&fakeTensor{name: "token_embd.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
})
byName := map[string]uint32{}
for _, tensor := range tensors {
byName[tensor.Name] = tensor.Kind
}
if _, ok := byName["token_embd.weight"]; ok {
t.Fatalf("projector tensors included text tensor")
}
if got := byName["v.position_embd.weight"]; got != tensorKindFP32 {
t.Fatalf("position embedding was not promoted to F32: %d", got)
}
if got := byName["v.blk.0.attn_qkv.weight"]; got != tensorKindBF16 {
t.Fatalf("BF16 projector tensor was not preserved: %d", got)
}
for _, name := range []string{
"mm.0.weight",
"mm.2.bias",
"v.post_ln.weight",
"v.deepstack.5.fc1.weight",
"v.deepstack.11.norm.bias",
} {
if _, ok := byName[name]; !ok {
t.Fatalf("missing projector tensor %q", name)
}
}
firstTensor := tensorsByName(tensors)["v.patch_embd.weight"]
if firstTensor == nil {
t.Fatalf("first patch embedding slice missing")
}
if got, want := firstTensor.Shape, []uint64{2, 2, 1, 2}; !slices.Equal(got, want) {
t.Fatalf("unexpected first patch shape: got %v want %v", got, want)
}
if got, want := readTensorData(t, firstTensor), []float32{0, 1, 4, 5, 8, 9, 12, 13}; !slices.Equal(got, want) {
t.Fatalf("unexpected first patch data: got %v want %v", got, want)
}
secondTensor := tensorsByName(tensors)["v.patch_embd.weight.1"]
if secondTensor == nil {
t.Fatalf("second patch embedding slice missing")
}
if got, want := readTensorData(t, secondTensor), []float32{2, 3, 6, 7, 10, 11, 14, 15}; !slices.Equal(got, want) {
t.Fatalf("unexpected second patch data: got %v want %v", got, want)
}
}
func tensorsByName(tensors []*ggml.Tensor) map[string]*ggml.Tensor {
byName := map[string]*ggml.Tensor{}
for _, tensor := range tensors {
byName[tensor.Name] = tensor
}
return byName
}
+5
View File
@@ -22,6 +22,7 @@ type fakeTensor struct {
data []float32
sourceDType string
kind uint32
repacker Repacker
}
@@ -34,6 +35,9 @@ func (f fakeTensor) Shape() []uint64 {
}
func (f fakeTensor) Kind() uint32 {
if f.kind != 0 {
return f.kind
}
return 0
}
@@ -51,6 +55,7 @@ func (f fakeTensor) Clone() Tensor {
shape: slices.Clone(f.shape),
data: slices.Clone(f.data),
sourceDType: f.sourceDType,
kind: f.kind,
repacker: f.repacker,
}
}
+5
View File
@@ -149,6 +149,7 @@ func parseTokenizer(fsys fs.FS, specialTokenTypes []string) (*Tokenizer, error)
if err := json.Unmarshal(bts, &sv.AddToken); err != nil {
return nil, err
}
sv.AddTokenSet = true
}
if bts, ok := p[fmt.Sprintf("%s_token", st)]; ok {
@@ -314,6 +315,10 @@ type SpecialVocabulary struct {
ID int
Content string
AddToken bool
// AddTokenSet tracks whether tokenizer_config.json explicitly defined the
// add_*_token setting. Missing and explicit false have different GGUF
// semantics for some tokenizers.
AddTokenSet bool
// IDs is populated by generation_config.json
IDs []int32
+24 -4
View File
@@ -184,8 +184,8 @@ func TestParseTokenizer(t *testing.T) {
},
SpecialVocabulary: []*SpecialVocabulary{
{Type: "pad", Content: "<pad>", ID: 0, AddToken: false},
{Type: "eos", Content: "<eos>", ID: 1, AddToken: false},
{Type: "bos", Content: "<bos>", ID: 2, AddToken: true},
{Type: "eos", Content: "<eos>", ID: 1, AddToken: false, AddTokenSet: true},
{Type: "bos", Content: "<bos>", ID: 2, AddToken: true, AddTokenSet: true},
{Type: "unk", Content: "<unk>", ID: 3, AddToken: false},
},
Pre: "default",
@@ -380,8 +380,8 @@ func TestParseTokenizer(t *testing.T) {
Types: []int32{3, 3, 3, 3},
},
SpecialVocabulary: []*SpecialVocabulary{
{Type: "eos", Content: "<eos>", ID: 1, IDs: []int32{1, 2, 3}, AddToken: false},
{Type: "bos", Content: "<bos>", ID: 0, AddToken: true},
{Type: "eos", Content: "<eos>", ID: 1, IDs: []int32{1, 2, 3}, AddToken: false, AddTokenSet: true},
{Type: "bos", Content: "<bos>", ID: 0, AddToken: true, AddTokenSet: true},
},
Pre: "default",
},
@@ -423,3 +423,23 @@ func TestParseTokenizer(t *testing.T) {
})
}
}
func TestModelParametersKVOmitsMissingAddToken(t *testing.T) {
kv := ModelParameters{}.KV(&Tokenizer{
Vocabulary: &Vocabulary{Model: "gpt2"},
SpecialVocabulary: []*SpecialVocabulary{
{Type: "bos", Content: "<bos>", ID: 1},
{Type: "eos", Content: "<eos>", ID: 2, AddToken: false, AddTokenSet: true},
},
})
if _, ok := kv["tokenizer.ggml.add_bos_token"]; ok {
t.Errorf("tokenizer.ggml.add_bos_token should be omitted when add_bos_token is absent")
}
if got := kv["tokenizer.ggml.bos_token_id"]; got != uint32(1) {
t.Errorf("tokenizer.ggml.bos_token_id = %v, want 1", got)
}
if got, ok := kv["tokenizer.ggml.add_eos_token"]; !ok || got != false {
t.Errorf("tokenizer.ggml.add_eos_token = %v, %v; want explicit false", got, ok)
}
}
+487
View File
@@ -0,0 +1,487 @@
// AMD discovery needs a small amount of backend-specific handling beyond the
// generic llama-server device list. ROCm devices expose their real capability
// as gfx targets, and the shipped rocBLAS kernels define which of those
// targets are actually usable. On Linux, KFD topology and DRM sysfs attributes
// provide the integrated-vs-discrete signal needed for scheduler decisions. On
// Windows, older HIP driver installs can also leave ROCm libraries present but
// too old to support GPU inference. These helpers keep that extra validation
// and warning logic in one place.
package discover
import (
"bufio"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
"github.com/ollama/ollama/ml"
)
// gfxTargetRegex matches ROCm stderr lines like:
//
// Device 0: AMD Radeon RX 6700 XT, gfx1031 (0x1031), VMM: no, Wave Size: 32, VRAM: 12272 MiB
// Device 1: AMD Radeon Pro VII, gfx906:sramecc+:xnack- (0x906), VMM: no, Wave Size: 64, VRAM: 16368 MiB
var gfxTargetRegex = regexp.MustCompile(
`Device\s+(\d+):.*,\s+(gfx[0-9a-f]+)[\s:(]`,
)
var pciIDRegex = regexp.MustCompile(`^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-7]$`)
func parseROCmGFXTargets(output string) map[int]string {
gfxByIndex := make(map[int]string)
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
if matches := gfxTargetRegex.FindStringSubmatch(scanner.Text()); matches != nil {
idx, _ := strconv.Atoi(matches[1])
gfxByIndex[idx] = matches[2]
}
}
return gfxByIndex
}
func parseGFXTarget(gfx string) (int, int) {
gfx, ok := strings.CutPrefix(gfx, "gfx")
if !ok || len(gfx) < 3 {
return 0, 0
}
major, err := strconv.ParseInt(gfx[:len(gfx)-2], 16, 32)
if err != nil {
return 0, 0
}
minor, err := strconv.ParseInt(gfx[len(gfx)-2:], 16, 32)
if err != nil {
return 0, 0
}
return int(major), int(minor)
}
// HSA_OVERRIDE_GFX_VERSION changes the effective HIP/rocBLAS target even
// though KFD/sysfs still reports the physical ASIC.
func hsaOverrideGFXTarget() string {
return rocmGFXTargetOverride(os.Getenv("HSA_OVERRIDE_GFX_VERSION"))
}
func rocmGFXTargetOverride(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
if strings.HasPrefix(value, "gfx") {
if major, minor := parseGFXTarget(value); major != 0 || minor != 0 {
return value
}
return ""
}
parts := strings.Split(value, ".")
if len(parts) != 3 {
return ""
}
var digits [3]uint64
for i, part := range parts {
digit, err := strconv.ParseUint(part, 10, 8)
if err != nil || digit > 0xf {
return ""
}
digits[i] = digit
}
return "gfx" +
strconv.FormatUint(digits[0], 10) +
strconv.FormatUint(digits[1], 16) +
strconv.FormatUint(digits[2], 16)
}
func setROCmGFXTarget(device *ml.DeviceInfo, gfx string) {
if gfx == "" || device.Library != "ROCm" {
return
}
device.GFXTarget = gfx
device.ComputeMajor, device.ComputeMinor = parseGFXTarget(gfx)
}
// rocblasGFXTargets scans the rocblas library directory for supported gfx targets
// by looking for TensileLibrary_lazy_gfxNNNN.dat files.
func rocblasGFXTargets(libDirs []string) map[string]bool {
targets := make(map[string]bool)
for _, dir := range libDirs {
files, _ := filepath.Glob(filepath.Join(dir, "rocblas", "library", "TensileLibrary_lazy_gfx*.dat"))
for _, f := range files {
base := filepath.Base(f)
if t, ok := strings.CutPrefix(base, "TensileLibrary_lazy_"); ok {
if t, ok = strings.CutSuffix(t, ".dat"); ok {
targets[t] = true
}
}
}
}
return targets
}
type rocmLinuxSysfsDevice struct {
pciID string
gfxTarget string
integrated bool
known bool
}
func refineLinuxROCmDevices(devices []ml.DeviceInfo) []ml.DeviceInfo {
if runtime.GOOS != "linux" {
return devices
}
applyLinuxROCmRefinement(devices, "/sys")
return devices
}
func applyLinuxROCmRefinement(devices []ml.DeviceInfo, sysfsRoot string) bool {
var rocmIndexes []int
for i, device := range devices {
if device.Library == "ROCm" {
rocmIndexes = append(rocmIndexes, i)
}
}
if len(rocmIndexes) == 0 {
return false
}
sysfsDevices, err := readROCmLinuxSysfsDevices(sysfsRoot)
if err != nil {
slog.Debug("linux rocm device refinement unavailable", "error", err)
return false
}
if len(sysfsDevices) != len(rocmIndexes) {
slog.Debug("linux rocm device refinement skipped: device count mismatch",
"llama_server_count", len(rocmIndexes), "kfd_count", len(sysfsDevices))
return false
}
byPCI := map[string]rocmLinuxSysfsDevice{}
byGFX := uniqueROCmSysfsDevicesByGFX(sysfsDevices)
for _, sysfsDevice := range sysfsDevices {
if sysfsDevice.pciID != "" {
byPCI[strings.ToLower(sysfsDevice.pciID)] = sysfsDevice
}
}
refined := 0
for i, rocmIndex := range rocmIndexes {
device := &devices[rocmIndex]
sysfsDevice, ok := matchROCmLinuxSysfsDevice(*device, i, sysfsDevices, byPCI, byGFX)
if !ok {
slog.Debug("linux rocm device refinement skipped: no stable match",
"device", device.Name, "pci_id", device.PCIID, "gfx", device.GFXTarget)
continue
}
applyROCmLinuxSysfsDevice(device, sysfsDevice)
refined++
}
if refined == 0 {
return false
}
slog.Debug("linux rocm device refinement applied", "devices", refined)
return true
}
func uniqueROCmSysfsDevicesByGFX(sysfsDevices []rocmLinuxSysfsDevice) map[string]rocmLinuxSysfsDevice {
byGFX := map[string]rocmLinuxSysfsDevice{}
duplicates := map[string]bool{}
for _, sysfsDevice := range sysfsDevices {
if sysfsDevice.gfxTarget == "" {
continue
}
if _, ok := byGFX[sysfsDevice.gfxTarget]; ok {
duplicates[sysfsDevice.gfxTarget] = true
continue
}
byGFX[sysfsDevice.gfxTarget] = sysfsDevice
}
for gfx := range duplicates {
delete(byGFX, gfx)
}
return byGFX
}
func matchROCmLinuxSysfsDevice(device ml.DeviceInfo, index int, sysfsDevices []rocmLinuxSysfsDevice, byPCI, byGFX map[string]rocmLinuxSysfsDevice) (rocmLinuxSysfsDevice, bool) {
// ROCm visibility envs can remap backend ordinals while sysfs stays in
// physical KFD order, so prefer stable identity before index fallback.
if device.PCIID != "" {
if sysfsDevice, ok := byPCI[strings.ToLower(device.PCIID)]; ok {
return sysfsDevice, true
}
}
if device.GFXTarget != "" {
if sysfsDevice, ok := byGFX[device.GFXTarget]; ok {
return sysfsDevice, true
}
}
if index >= len(sysfsDevices) {
return rocmLinuxSysfsDevice{}, false
}
sysfsDevice := sysfsDevices[index]
if sysfsDevice.gfxTarget != "" && device.GFXTarget != "" && sysfsDevice.gfxTarget != device.GFXTarget {
slog.Debug("linux rocm device refinement index mismatch",
"device", device.Name, "llama_server_gfx", device.GFXTarget, "kfd_gfx", sysfsDevice.gfxTarget)
return rocmLinuxSysfsDevice{}, false
}
return sysfsDevice, true
}
func applyROCmLinuxSysfsDevice(device *ml.DeviceInfo, sysfsDevice rocmLinuxSysfsDevice) {
if sysfsDevice.pciID != "" {
device.PCIID = sysfsDevice.pciID
}
if sysfsDevice.known {
device.Integrated = sysfsDevice.integrated
}
}
func readROCmLinuxSysfsDevices(sysfsRoot string) ([]rocmLinuxSysfsDevice, error) {
nodeRoot := filepath.Join(sysfsRoot, "class", "kfd", "kfd", "topology", "nodes")
entries, err := os.ReadDir(nodeRoot)
if err != nil {
return nil, err
}
sort.Slice(entries, func(i, j int) bool {
left, _ := strconv.Atoi(entries[i].Name())
right, _ := strconv.Atoi(entries[j].Name())
return left < right
})
var devices []rocmLinuxSysfsDevice
for _, entry := range entries {
if !entry.IsDir() {
continue
}
properties, err := readKFDNodeProperties(filepath.Join(nodeRoot, entry.Name(), "properties"))
if err != nil || !properties.isGPU() {
continue
}
device, err := readROCmDRMDevice(sysfsRoot, properties.drmRenderMinor)
if err != nil {
slog.Debug("linux rocm sysfs device skipped", "node", entry.Name(), "error", err)
continue
}
device.gfxTarget = gfxTargetFromKFDVersion(properties.gfxTargetVersion)
devices = append(devices, device)
}
return devices, nil
}
type kfdNodeProperties struct {
vendorID uint64
deviceID uint64
drmRenderMinor int
gfxTargetVersion uint64
}
func (p kfdNodeProperties) isGPU() bool {
return p.vendorID != 0 && p.deviceID != 0 && p.drmRenderMinor != 0
}
func readKFDNodeProperties(path string) (kfdNodeProperties, error) {
file, err := os.Open(path)
if err != nil {
return kfdNodeProperties{}, err
}
defer file.Close()
values := make(map[string]string)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) >= 2 {
values[fields[0]] = fields[1]
}
}
if err := scanner.Err(); err != nil {
return kfdNodeProperties{}, err
}
vendorID, _ := parseSysfsUint(values["vendor_id"])
deviceID, _ := parseSysfsUint(values["device_id"])
renderMinor, _ := parseSysfsUint(values["drm_render_minor"])
gfxVersion, _ := parseSysfsUint(values["gfx_target_version"])
return kfdNodeProperties{
vendorID: vendorID,
deviceID: deviceID,
drmRenderMinor: int(renderMinor),
gfxTargetVersion: gfxVersion,
}, nil
}
func readROCmDRMDevice(sysfsRoot string, renderMinor int) (rocmLinuxSysfsDevice, error) {
devicePath := filepath.Join(sysfsRoot, "class", "drm", "renderD"+strconv.Itoa(renderMinor), "device")
resolvedDevicePath, err := filepath.EvalSymlinks(devicePath)
if err != nil {
return rocmLinuxSysfsDevice{}, err
}
vendor, err := readSysfsString(filepath.Join(resolvedDevicePath, "vendor"))
if err != nil {
return rocmLinuxSysfsDevice{}, err
}
if !strings.EqualFold(vendor, "0x1002") {
return rocmLinuxSysfsDevice{}, nil
}
driver, err := readSysfsDriverName(filepath.Join(resolvedDevicePath, "driver"))
if err != nil {
return rocmLinuxSysfsDevice{}, err
}
if driver != "amdgpu" {
return rocmLinuxSysfsDevice{}, nil
}
device := rocmLinuxSysfsDevice{pciID: pciIDFromPath(resolvedDevicePath)}
if sysfsFileExists(filepath.Join(resolvedDevicePath, "mem_info_vram_vendor")) ||
sysfsFileExists(filepath.Join(resolvedDevicePath, "board_info")) {
device.known = true
return device, nil
}
vramTotal, ok := readROCmLinuxMemoryInfo(resolvedDevicePath, "mem_info_vram_total")
if !ok {
return device, nil
}
gttTotal, ok := readROCmLinuxMemoryInfo(resolvedDevicePath, "mem_info_gtt_total")
if !ok {
return device, nil
}
const (
maxIntegratedVRAM = 4 << 30
minSharedGTT = 8 << 30
)
if vramTotal > 0 && vramTotal <= maxIntegratedVRAM && gttTotal >= minSharedGTT && gttTotal >= 4*vramTotal {
device.integrated = true
device.known = true
}
return device, nil
}
func readROCmLinuxMemoryInfo(devicePath, name string) (uint64, bool) {
value, err := readSysfsUint(filepath.Join(devicePath, name))
return value, err == nil
}
func readSysfsString(path string) (string, error) {
data, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.TrimSpace(string(data)), nil
}
func readSysfsDriverName(path string) (string, error) {
driver, readErr := readSysfsString(path)
if readErr == nil {
return driver, nil
}
driverPath, err := filepath.EvalSymlinks(path)
if err == nil {
return filepath.Base(driverPath), nil
}
return "", readErr
}
func readSysfsUint(path string) (uint64, error) {
value, err := readSysfsString(path)
if err != nil {
return 0, err
}
return parseSysfsUint(value)
}
func parseSysfsUint(value string) (uint64, error) {
return strconv.ParseUint(strings.TrimSpace(value), 0, 64)
}
func sysfsFileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func pciIDFromPath(path string) string {
base := filepath.Base(path)
if pciIDRegex.MatchString(base) {
return base
}
return ""
}
func gfxTargetFromKFDVersion(version uint64) string {
if version == 0 {
return ""
}
major := version / 10000
minor := (version / 100) % 100
stepping := version % 100
if minor > 0xf || stepping > 0xf {
return ""
}
return "gfx" + strconv.FormatUint(major, 10) + strconv.FormatUint(minor, 16) + strconv.FormatUint(stepping, 16)
}
// filterUnsupportedROCmDevices removes ROCm devices whose gfx target doesn't have
// matching rocblas kernels bundled.
func filterUnsupportedROCmDevices(devices []ml.DeviceInfo, libDirs []string) []ml.DeviceInfo {
supported := rocblasGFXTargets(libDirs)
if len(supported) == 0 {
return devices
}
override := hsaOverrideGFXTarget()
var filtered []ml.DeviceInfo
for _, dev := range devices {
if dev.Library != "ROCm" {
filtered = append(filtered, dev)
continue
}
setROCmGFXTarget(&dev, override)
gfx := dev.GFXTarget
if gfx == "" {
filtered = append(filtered, dev)
continue
}
if supported[gfx] {
filtered = append(filtered, dev)
} else {
slog.Warn("dropping ROCm device — no rocblas support for gfx target",
"device", dev.Name, "gfx_target", gfx, "supported", supported,
"hint", "set HSA_OVERRIDE_GFX_VERSION to map to a supported target")
}
}
return filtered
}
func detectOldAMDDriverWindows() {
if runtime.GOOS != "windows" {
return
}
_, errV6 := exec.LookPath("amdhip64_6.dll")
_, errV7 := exec.LookPath("amdhip64_7.dll")
if errV6 == nil && errV7 != nil {
slog.Warn("AMD driver is too old. Update your AMD driver to enable GPU inference.")
}
}
+289
View File
@@ -0,0 +1,289 @@
package discover
import (
"os"
"path/filepath"
"runtime"
"strconv"
"testing"
"github.com/ollama/ollama/ml"
)
func TestApplyLinuxROCmRefinement(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake Linux PCI sysfs paths use ':' which is not valid in Windows filenames")
}
tests := []struct {
name string
nodes []fakeROCmNode
devices []ml.DeviceInfo
applied bool
wantIntegrated []bool
wantPCIIDs []string
}{
{
name: "apu is integrated",
nodes: []fakeROCmNode{{
node: 1,
renderMinor: 128,
gfxVersion: "90012",
vramTotal: 2 << 30,
gttTotal: 32 << 30,
}},
devices: []ml.DeviceInfo{{
DeviceID: ml.DeviceID{ID: "0", Library: "ROCm"},
Name: "ROCm0",
GFXTarget: "gfx90c",
}},
applied: true,
wantIntegrated: []bool{true},
},
{
name: "low vram dgpu is not integrated",
nodes: []fakeROCmNode{{
node: 1,
renderMinor: 128,
gfxVersion: "100601",
vramTotal: 4 << 30,
gttTotal: 32 << 30,
vramVendor: true,
boardInfo: true,
}},
devices: []ml.DeviceInfo{{
DeviceID: ml.DeviceID{ID: "0", Library: "ROCm"},
Name: "ROCm0",
GFXTarget: "gfx1061",
}},
applied: true,
wantIntegrated: []bool{false},
},
{
name: "mixed system follows kfd order not drm order",
nodes: []fakeROCmNode{
{
node: 1,
renderMinor: 129,
gfxVersion: "110000",
vramTotal: 48 << 30,
gttTotal: 64 << 30,
vramVendor: true,
boardInfo: true,
},
{
node: 2,
renderMinor: 128,
gfxVersion: "110003",
vramTotal: 512 << 20,
gttTotal: 32 << 30,
},
},
devices: []ml.DeviceInfo{
{DeviceID: ml.DeviceID{ID: "0", Library: "ROCm"}, Name: "ROCm0", GFXTarget: "gfx1100"},
{DeviceID: ml.DeviceID{ID: "1", Library: "ROCm"}, Name: "ROCm1", GFXTarget: "gfx1103"},
},
applied: true,
wantIntegrated: []bool{false, true},
},
{
name: "remapped visible order matches existing pci identity",
nodes: []fakeROCmNode{
{
node: 1,
renderMinor: 128,
pciID: "0000:e3:00.0",
gfxVersion: "110000",
vramTotal: 48 << 30,
gttTotal: 64 << 30,
vramVendor: true,
boardInfo: true,
},
{
node: 2,
renderMinor: 129,
pciID: "0000:c3:00.0",
gfxVersion: "120000",
vramTotal: 2 << 30,
gttTotal: 32 << 30,
},
},
devices: []ml.DeviceInfo{
{DeviceID: ml.DeviceID{ID: "0", Library: "ROCm"}, Name: "ROCm0", GFXTarget: "gfx1200", PCIID: "0000:c3:00.0"},
{DeviceID: ml.DeviceID{ID: "1", Library: "ROCm"}, Name: "ROCm1", GFXTarget: "gfx1100", PCIID: "0000:e3:00.0"},
},
applied: true,
wantIntegrated: []bool{true, false},
wantPCIIDs: []string{"0000:c3:00.0", "0000:e3:00.0"},
},
{
name: "remapped visible order matches unique gfx when pci is absent",
nodes: []fakeROCmNode{
{
node: 1,
renderMinor: 128,
pciID: "0000:e3:00.0",
gfxVersion: "110000",
vramTotal: 48 << 30,
gttTotal: 64 << 30,
vramVendor: true,
boardInfo: true,
},
{
node: 2,
renderMinor: 129,
pciID: "0000:c3:00.0",
gfxVersion: "120000",
vramTotal: 2 << 30,
gttTotal: 32 << 30,
},
},
devices: []ml.DeviceInfo{
{DeviceID: ml.DeviceID{ID: "0", Library: "ROCm"}, Name: "ROCm0", GFXTarget: "gfx1200"},
{DeviceID: ml.DeviceID{ID: "1", Library: "ROCm"}, Name: "ROCm1", GFXTarget: "gfx1100"},
},
applied: true,
wantIntegrated: []bool{true, false},
wantPCIIDs: []string{"0000:c3:00.0", "0000:e3:00.0"},
},
{
name: "missing kfd data leaves devices unchanged",
devices: []ml.DeviceInfo{{
DeviceID: ml.DeviceID{ID: "0", Library: "ROCm"},
Name: "ROCm0",
Integrated: true,
}},
wantIntegrated: []bool{true},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sysfsRoot := t.TempDir()
for _, node := range tt.nodes {
writeFakeROCmNode(t, sysfsRoot, node)
}
devices := append([]ml.DeviceInfo(nil), tt.devices...)
applied := applyLinuxROCmRefinement(devices, sysfsRoot)
if applied != tt.applied {
t.Fatalf("applied = %v, want %v", applied, tt.applied)
}
for i, want := range tt.wantIntegrated {
if devices[i].Integrated != want {
t.Fatalf("device %d integrated = %v, want %v", i, devices[i].Integrated, want)
}
}
for i, want := range tt.wantPCIIDs {
if devices[i].PCIID != want {
t.Fatalf("device %d PCIID = %q, want %q", i, devices[i].PCIID, want)
}
}
})
}
}
func TestSameRefreshDeviceMatchesROCmByPCI(t *testing.T) {
updated := ml.DeviceInfo{
DeviceID: ml.DeviceID{ID: "0", Library: "ROCm"},
PCIID: "0000:c3:00.0",
}
existing := ml.DeviceInfo{
DeviceID: ml.DeviceID{ID: "1", Library: "ROCm"},
PCIID: "0000:C3:00.0",
}
if !sameRefreshDevice(updated, existing) {
t.Fatal("sameRefreshDevice did not match remapped ROCm device by PCI ID")
}
}
func TestFilterUnsupportedROCmDevicesRespectsHSAOverride(t *testing.T) {
t.Setenv("HSA_OVERRIDE_GFX_VERSION", "10.3.0")
libDir := t.TempDir()
rocblasDir := filepath.Join(libDir, "rocblas", "library")
if err := os.MkdirAll(rocblasDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(rocblasDir, "TensileLibrary_lazy_gfx1030.dat"), nil, 0o644); err != nil {
t.Fatal(err)
}
devices := filterUnsupportedROCmDevices([]ml.DeviceInfo{{
DeviceID: ml.DeviceID{ID: "0", Library: "ROCm"},
Name: "ROCm0",
GFXTarget: "gfx1031",
ComputeMajor: 0x10,
ComputeMinor: 0x31,
}}, []string{libDir})
if len(devices) != 1 {
t.Fatalf("got %d devices, want 1", len(devices))
}
if got := devices[0].GFXTarget; got != "gfx1030" {
t.Fatalf("GFXTarget = %q, want gfx1030", got)
}
if got := devices[0].Compute(); got != "gfx1030" {
t.Fatalf("Compute() = %q, want gfx1030", got)
}
}
type fakeROCmNode struct {
node int
renderMinor int
pciID string
gfxVersion string
vramTotal uint64
gttTotal uint64
vramVendor bool
boardInfo bool
}
func writeFakeROCmNode(t *testing.T, sysfsRoot string, node fakeROCmNode) {
t.Helper()
nodeDir := filepath.Join(sysfsRoot, "class", "kfd", "kfd", "topology", "nodes", strconv.Itoa(node.node))
if err := os.MkdirAll(nodeDir, 0o755); err != nil {
t.Fatal(err)
}
properties := "vendor_id 4098\n" +
"device_id 1234\n" +
"drm_render_minor " + strconv.Itoa(node.renderMinor) + "\n" +
"gfx_target_version " + node.gfxVersion + "\n"
if err := os.WriteFile(filepath.Join(nodeDir, "properties"), []byte(properties), 0o644); err != nil {
t.Fatal(err)
}
deviceDir := filepath.Join(sysfsRoot, "class", "drm", "renderD"+strconv.Itoa(node.renderMinor), "device")
if node.pciID != "" {
targetDir := filepath.Join(sysfsRoot, "devices", node.pciID)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(deviceDir), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(targetDir, deviceDir); err != nil {
t.Skipf("symlink unavailable for fake sysfs PCI path: %v", err)
}
deviceDir = targetDir
} else if err := os.MkdirAll(deviceDir, 0o755); err != nil {
t.Fatal(err)
}
writeFakeSysfsFile(t, deviceDir, "vendor", "0x1002\n")
writeFakeSysfsFile(t, deviceDir, "driver", "amdgpu\n")
writeFakeSysfsFile(t, deviceDir, "mem_info_vram_total", strconv.FormatUint(node.vramTotal, 10)+"\n")
writeFakeSysfsFile(t, deviceDir, "mem_info_gtt_total", strconv.FormatUint(node.gttTotal, 10)+"\n")
if node.vramVendor {
writeFakeSysfsFile(t, deviceDir, "mem_info_vram_vendor", "samsung\n")
}
if node.boardInfo {
writeFakeSysfsFile(t, deviceDir, "board_info", "type : cem\n")
}
}
func writeFakeSysfsFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
-142
View File
@@ -4,13 +4,8 @@ import (
"bufio"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
@@ -92,143 +87,6 @@ func getUint64ValueFromFile(path string) (uint64, error) {
return 0, errors.New("empty file content")
}
const CpuInfoFilename = "/proc/cpuinfo"
type linuxCpuInfo struct {
ID string `cpuinfo:"processor"`
VendorID string `cpuinfo:"vendor_id"`
ModelName string `cpuinfo:"model name"`
PhysicalID string `cpuinfo:"physical id"`
Siblings string `cpuinfo:"siblings"`
CoreID string `cpuinfo:"core id"`
}
func GetCPUDetails() []CPU {
file, err := os.Open(CpuInfoFilename)
if err != nil {
slog.Warn("failed to get CPU details", "error", err)
return nil
}
defer file.Close()
cpus := linuxCPUDetails(file)
return overwriteThreadCountByLinuxCgroups(cpus)
}
func overwriteThreadCountByLinuxCgroups(cpus []CPU) []CPU {
file, err := os.Open("/sys/fs/cgroup/cpu.max")
if err != nil {
return cpus
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if sl := strings.Split(line, " "); len(sl) == 2 {
allowdUs, err := strconv.ParseInt(sl[0], 10, 64)
if err != nil {
slog.Warn("failed to parse CPU allowed micro secs", "error", err)
return cpus
}
unitUs, err := strconv.ParseInt(sl[1], 10, 64)
if err != nil {
slog.Warn("failed to parse CPU unit micro secs", "error", err)
return cpus
}
threads := int(max(allowdUs/unitUs, 1))
cpu := cpus[0]
cpu.CoreCount = threads
cpu.ThreadCount = threads
return []CPU{cpu}
}
}
return cpus
}
func linuxCPUDetails(file io.Reader) []CPU {
reColumns := regexp.MustCompile("\t+: ")
scanner := bufio.NewScanner(file)
cpuInfos := []linuxCpuInfo{}
cpu := &linuxCpuInfo{}
for scanner.Scan() {
line := scanner.Text()
if sl := reColumns.Split(line, 2); len(sl) > 1 {
t := reflect.TypeOf(cpu).Elem()
s := reflect.ValueOf(cpu).Elem()
for i := range t.NumField() {
field := t.Field(i)
tag := field.Tag.Get("cpuinfo")
if tag == sl[0] {
s.FieldByName(field.Name).SetString(sl[1])
break
}
}
} else if strings.TrimSpace(line) == "" && cpu.ID != "" {
cpuInfos = append(cpuInfos, *cpu)
cpu = &linuxCpuInfo{}
}
}
if cpu.ID != "" {
cpuInfos = append(cpuInfos, *cpu)
}
// Process the sockets/cores/threads
socketByID := map[string]*CPU{}
coreBySocket := map[string]map[string]struct{}{}
threadsByCoreBySocket := map[string]map[string]int{}
for _, c := range cpuInfos {
if _, found := socketByID[c.PhysicalID]; !found {
socketByID[c.PhysicalID] = &CPU{
ID: c.PhysicalID,
VendorID: c.VendorID,
ModelName: c.ModelName,
}
coreBySocket[c.PhysicalID] = map[string]struct{}{}
threadsByCoreBySocket[c.PhysicalID] = map[string]int{}
}
if c.CoreID != "" {
coreBySocket[c.PhysicalID][c.PhysicalID+":"+c.CoreID] = struct{}{}
threadsByCoreBySocket[c.PhysicalID][c.PhysicalID+":"+c.CoreID]++
} else {
coreBySocket[c.PhysicalID][c.PhysicalID+":"+c.ID] = struct{}{}
threadsByCoreBySocket[c.PhysicalID][c.PhysicalID+":"+c.ID]++
}
}
// Tally up the values from the tracking maps
for id, s := range socketByID {
s.CoreCount = len(coreBySocket[id])
s.ThreadCount = 0
// This only works if HT is enabled, consider a more reliable model, maybe cache size comparisons?
efficiencyCoreCount := 0
for _, threads := range threadsByCoreBySocket[id] {
s.ThreadCount += threads
if threads == 1 {
efficiencyCoreCount++
}
}
if efficiencyCoreCount == s.CoreCount {
// 1:1 mapping means they're not actually efficiency cores, but regular cores
s.EfficiencyCoreCount = 0
} else {
s.EfficiencyCoreCount = efficiencyCoreCount
}
}
keys := make([]string, 0, len(socketByID))
result := make([]CPU, 0, len(socketByID))
for k := range socketByID {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
result = append(result, *socketByID[k])
}
return result
}
func IsNUMA() bool {
ids := map[string]any{}
packageIds, _ := filepath.Glob("/sys/devices/system/cpu/cpu*/topology/physical_package_id")
Loaded 100 of 1202 files, more files were not shown because too many files have changed in this diff. Show more