Compare commits

...
Author SHA1 Message Date
ParthSareen 6a6b975607 ci: test darwin xcode pin 2026-06-17 12:08:27 -07:00
Patrick Devine 8c432fc88a llama: update llama.cpp to b9672 (#16775) 2026-06-16 23:15:52 -07:00
Jeffrey Morgan acfb50d9af models: add cohere2_moe (Command A / North) to the MLX engine (#16670)
Implements Cohere2MoeForCausalLM (e.g. CohereLabs/North-Mini-Code-1.0)
2026-06-16 23:15:21 -07:00
Jeffrey Morgan 0f047feef5 llm: context shift allow shiftable prompts (#16764) 2026-06-16 12:55:52 -07:00
Patrick Devine 9e4ed74efe integration: look for the "hf" tool in integration tests (#16765)
The "huggingface-cli" tool is deprecated, so only try to use the "hf" tool.
2026-06-16 11:04:54 -07:00
Jeffrey Morgan bbb40a0a6c server: context shift for context windows larger than 8k, add error when hitting context limit (#16712) 2026-06-15 11:36:50 -07:00
Jeffrey Morgan 993acc7504 model: update lfm2 parser/renderer for optional thinking (#16359) 2026-06-14 20:37:08 -07:00
Jeffrey Morgan 7ea692cb2b llama: update llama.cpp to b9637 (#16609) 2026-06-14 20:05:08 -07:00
Parth Sareen 12e04379cd launch: Fix launch provider drift (#16683) 2026-06-11 17:21:46 -07:00
Parafee41 f8a48df24d llm: decouple prompt caching from context shift (#16639)
This PR separates prompt caching from the public shift request option for native llama-server requests.

Previously, shift controlled two different mechanisms:

context shifting / overflow behavior
per-request llama-server cache_prompt
That meant callers could not request shift: false without also disabling prompt caching.

Fixes #16635
2026-06-11 16:05:24 -07:00
Patrick Devine 82e0ddb6fe mlxrunner: harden linear/embedding layers against over-promotion (#16682)
Adding/Multiplying a tensor by a scalar w/ a different data type
can cause the tensor to be promoted and cause performance issues.

This change adds several guards against over-promotion.
2026-06-11 13:56:25 -07:00
Jesse Gross 1abd56b6e6 mlxrunner: record committed MTP drafts before streaming them
The batched MTP accept paths advance the cache by the whole accepted run
before streaming it to the client. If the stream was cancelled partway
(e.g. the caller disconnects), the loop returned before recording the
remaining accepted tokens, leaving the cache offset ahead of
session.outputs. close() then indexed the token log past its end and
panicked with a slice-bounds error.

Record the whole run to session.outputs before streaming any of it, so a
cancelled stream can no longer desync the cache from the token log.

The same bug is present on main, with identical mechanics: the accept
paths there commit the cache to before+accepted and then stream in a loop
that returns on cancellation before recording the rest.
2026-06-09 00:39:19 -07:00
Jesse Gross ded2db7d86 mlxrunner: capture prefill snapshots across the forward
Prefill no longer splits its batch at each requested snapshot offset. The
session schedules the pending offsets on every cache before prefill, runs the
forward in full-size chunks, and attaches the captured snapshots to the trie
afterward. Offsets the prefill never crosses (it leaves one token for decode
seeding) are dropped instead of materializing a node for tokens never written,
and snapshots from an abandoned prefill are released on session close.
2026-06-09 00:39:19 -07:00
Jesse Gross d00622060f mlxrunner: drive MTP speculation through cache snapshots
Speculation used a parallel hierarchy of wrapper cache types that shadowed
the live caches and reconciled against them on commit. Replace it with
snapshot/restore on the live caches themselves: a cache snapshots itself as
a write crosses each offset, and the runner commits a batched draft by
restoring to the accepted count. The wrappers and the comparison plumbing
around them are gone.

Snapshots are lazy. A KV or rotating capture indexes into the live buffer and
owns no memory until a destructive write forces a copy-out, so rejecting a
draft is free.

Recurrent layers now validate in the same batched pass rather than falling
back to serial. A gated-delta layer reports its interior split offsets and
hands back the recurrent state at each one, which the cache records as a
snapshot.
2026-06-09 00:39:19 -07:00
Jesse Gross 177aefb8a9 nn/recurrent: return per-boundary states from the gated-delta kernels
CausalConv1D and GatedDelta now run their scan in segments cut at optional
WithSnapshotSplits offsets and return the recurrent state at each boundary
instead of just the final state. The output is identical to the unsegmented
scan; segmenting only adds a few kernel launches, not extra recurrence compute.

This lets a batched forward capture interior recurrent state without re-running
the scan, which the cache will use for speculative validation rollback points.
RecurrentCache.Put and the Qwen3.5 layer now thread the boundary-state slices,
committing the final entry as the live state.
2026-06-09 00:39:19 -07:00
Jesse Gross 07588c64ee mlxrunner/cache: split KVCache and RotatingKVCache into their own files
cache.go had grown to hold every cache kind. Move KVCache (and its
speculative wrappers) to kvcache.go and RotatingKVCache (and its
sliding-window mask applier) to rotating.go, leaving cache.go with the
shared interfaces and the Speculation transaction. Pure relocation;
no behavior change.
2026-06-09 00:39:19 -07:00
Jesse Gross 4c97a940ca mlxthread: preserve the original stack when worker work panics
Work that panics on the locked MLX worker goroutine was recovered and
re-raised on the caller, so the printed trace pointed at the re-panic
site in this package rather than the code that actually panicked.

Capture the worker stack at recovery and carry it through a value that
implements error, so the runtime prints the original location in the
fatal trace.
2026-06-09 00:39:19 -07:00
Bruce MacDonald 74cbf1d2c2 docs: omp (#16552)
Add docs for explaining and setting up "oh my pi" (omp)
2026-06-08 11:43:51 -07:00
Bruce MacDonald 5c1e37eb67 docs: hermes desktop (#16549) 2026-06-08 11:43:11 -07:00
Jeffrey Morgan f0078ae476 docs: update docs examples to use Gemma 4 instead of Gemma 3 (#16607) 2026-06-07 12:43:13 -07:00
Jeffrey Morgan 96201a623a Add AGENTS.md and CLAUDE.md to root repository (#16604) 2026-06-07 10:57:59 -07:00
Daniel Hiltgen 9c94c2b11e docs: describe llama.cpp update process (#16603) 2026-06-07 10:27:47 -07:00
Parth Sareen e09b3f9fb5 openai: align models list with tags (#16556) 2026-06-05 17:59:05 -07:00
Bruce MacDonald a0099da2d1 launch: use native Windows Hermes config path (#16558) 2026-06-05 17:29:19 -07:00
Chris Chenandfuleinist 25e0e81e12 docs: update Zod example to use native toJSONSchema (#14746)
Co-authored-by: fuleinist <fuleinist@gmail.com>
2026-06-05 16:21:07 -07:00
Bruce MacDonald 87cff95af8 launch: oh-my-pi (#16410) 2026-06-04 17:49:49 -07:00
Patrick Devine 3ef69ef784 mlx: allow the embedding layer to use the nvfp4 global scale (#16527) 2026-06-04 17:40:01 -07:00
Michael Yang 1a7786be14 docs: add cloud model retirement (#16528) 2026-06-04 15:18:38 -07:00
Bruce MacDonald 3370ff8b1c launch: hermes-desktop app (#16516)
Add support to launch the hermes-desktop app alongside the hermes agent from ollama launch. It will go through the install on first run if hermes-desktop is not already installed.
2026-06-04 11:51:36 -07:00
Daniel Hiltgen 455f57457d llama.cpp version update (#16511)
Bump llama.cpp to b9509, which includes the upstream Gemma 4 12B multimodal projector fixes for the n_head=0 divide-by-zero crash seen on x86/CUDA/Linux/Windows.

Fixes #16479
Fixes #16489
Fixes #16491
Fixes #16492
Fixes #16495
2026-06-04 08:20:57 -07:00
Bruce MacDonald 1d955ed990 integrations: hermes windows install (#16487) 2026-06-03 17:40:45 -07:00
Eva H d071237131 docs: add Cline CLI integration doc (#16341) 2026-06-03 20:30:01 -04:00
Daniel Hiltgen 229a1303fb llama-server: fix gemma4 patch wiring (#16477)
This will fix the "clip.cpp:4399: Unknown projector type" crash.
2026-06-03 14:41:03 -07:00
Parth Sareen ac3d0657a2 launch: migrate pi (#16213) 2026-06-03 14:35:32 -07:00
Daniel Hiltgen 01557ff313 llama-server: allow GPU offload for projectors (#16473)
Special case Metal iGPUs to enable GPU offload.
2026-06-03 13:58:40 -07:00
Patrick Devine e5a38739b4 mlx: "requires" in modelfile is being ignored for mlx based models (#16469)
This change fixes an issue in `ollama create --experimental` which
is currently ignoring the REQUIRES command in a Modelfile.
2026-06-03 13:10:57 -07:00
Jeffrey Morgan 5f56a289b3 server: classify mmproj GGUFs as projector layers (#16472) 2026-06-03 12:59:34 -07:00
Eva H ad8cda255d launch: clean legacy codex profile before launch (#16467) 2026-06-03 14:49:31 -04:00
Daniel Hiltgen 3e1b4fe39d Kill llama-server during Windows cleanup (#16458)
Windows installer and app cleanup could leave llama-server.exe running when ollama.exe was killed directly, so cleanup now includes llama-server.exe and taskkill /T.
2026-06-03 10:25:12 -07:00
Daniel Hiltgen 52196f1a97 llama.cpp version update (#16463)
Bump llama.cpp to b9493 and refresh the Laguna compat patch for upstream enum/tokenizer movement and the renamed SWA layer bitmap field.
2026-06-03 10:20:30 -07:00
Patrick Devine 50bbda5660 models: add support for gemma4-12b (#16457) 2026-06-03 07:44:57 -07:00
Daniel Hiltgen 4b5bdd3b25 fix laguna patch build breakage (#16445)
Follow up to #16396

Fix kernel template instantiation so the symbols are exported in the library.
2026-06-02 16:35:19 -07:00
Daniel Hiltgen e828061b6e llm: ignore llama-server SSE ping comments (#16443)
llama.cpp b9478 added a default 30s SSE ping that emits colon-only comment frames (":\n\n") while streamed requests are idle; Ollama treated non-data SSE lines as JSON, so skip SSE comments in completion and chat streams.
2026-06-02 15:40:14 -07:00
Bruce MacDonald 7a2073d17b docs: configure hermes desktop app (#16440) 2026-06-02 14:32:10 -07:00
Daniel HiltgenandJeffrey Morgan c952708169 llama: add laguna (poolside) arch via a llama.cpp patch under llama/c… (#16396)
* llama: add laguna (poolside) arch via a llama.cpp patch under llama/compat/models

The pinned llama.cpp does not include poolside Laguna yet. Add it as an Ollama-owned source file plus a small registration patch under llama/compat/models/. apply-patch.cmake now applies every *.patch under llama/compat/ (the hooks patch plus each arch patch), so adding an architecture only adds files under llama/compat/models/ and needs no new cmake.

* cleanup patch to keep windows happy

---------

Co-authored-by: Jeffrey Morgan <jmorganca@gmail.com>
2026-06-02 13:17:08 -07:00
Parth Sareen f57d111754 launch: isolate Codex launch configuration (#16437) 2026-06-02 12:10:46 -07:00
Daniel Hiltgen c34a79a373 llama.cpp version update (#16426) 2026-06-02 11:46:56 -07:00
Daniel Hiltgen b051c9cf83 More harden app markdown URL handling (#16436) 2026-06-02 11:46:14 -07:00
Daniel Hiltgen b7b7fa0454 llm: detect llama-server load stalls from output (#16427)
llama-server model loads could time out after the fixed load duration even while tensor-loading progress dots were still being emitted, so track raw runner output activity and use OLLAMA_LOAD_TIMEOUT as a stall deadline.

Fixes #16416
Fixes #16412
2026-06-02 11:30:48 -07:00
Daniel Hiltgen 4c076813be discover: allow Radeon 8060S iGPU by default (#16429)
Default integrated GPU filtering dropped the supported ROCm gfx1151 Radeon 8060S unless OLLAMA_IGPU_ENABLE was set, so add a ROCm gfx-target allowlist with gfx1151 as the first admitted target.  This iGPU is a known-good iGPU.

Fixes #16423
2026-06-02 11:15:01 -07:00
Daniel Hiltgen 6780f0416a Harden app markdown URL handling (#16380) 2026-06-02 11:14:36 -07:00
Daniel Hiltgen 35fa277fa9 llm: include cached prompt tokens in llama-server counts (#16428)
llama-server reports newly processed prompt tokens separately from cached prompt tokens, so add cache_n to prompt_n to preserve Ollama's pre-0.30 full-context prompt_eval_count semantics.

Fixes #16414
2026-06-02 10:51:01 -07:00
Daniel Hiltgen 05747b02ab launch: fix opencode local model limits (#16425)
Local model metadata from /api/tags can include a context length without a max output limit, so omit OpenCode limit stanzas unless an output limit is known.

This preserves the pre-0.30 OpenCode behavior: local models did not receive a limit stanza because /api/tags did not expose context length, while cloud models still emit complete context/output limits.

Fixes #16424
2026-06-02 10:50:35 -07:00
Eva H 4e807fdedd cmd/launch: add Qwen code integration (#15900) 2026-06-01 19:51:32 -04:00
Daniel Hiltgen 7d3a6c3ae5 log template details to aid troubleshooting (#16403)
This cleans up the capabilities logic so we can log more information about the various options we consider as well as the final template version we use.
2026-06-01 16:25:44 -07:00
Eva H 06ff728246 feat(launch): show and auto-install Cline CLI (#16402) 2026-06-01 19:04:08 -04:00
Parth Sareen 2c71d8d7ca launch: migrate Codex config (#16397)
* launch: migrate Codex config
2026-06-01 13:46:41 -07:00
Eva H 00381496a3 cmd/launch: fix configure cline ollama provider via providers.json (#16352) 2026-06-01 16:41:40 -04:00
ZiTian ZhaoandParthSareen 5e9636fa05 launch: avoid legacy Codex App profiles (#16364)
* launch: avoid legacy Codex App profiles
---------

Co-authored-by: ParthSareen <parth.sareen@ollama.com>
2026-06-01 11:49:56 -07:00
Daniel Hiltgen 630882621b llama-server followups (#16353)
* llama-server followups

Misc fixes for #16031
- Add back dropped ROCm build flag for multi-GPU support on windows
- Fix amdhip64_*.dll version detection for "latest" selection
- Fix embeddings API for consistent normalize behavior with prior versions

* ci: set up for automated llama.cpp update testing

* reduce batch for fa-disabled, and constrained vram

* mlx: fix v3 load bug on m5

Imagegen was incorrectly loading v3 first.  This DRYs out the loading code so imagegen gets the same new v4/v3 selection logic.

* fix reload bug on embedding models

* bump version

* steer user how to enable iGPU when disabled
2026-06-01 10:44:21 -07:00
Patrick Devine 0e93ccc2cd convert: fixes for qwen3next model conversion (#16354)
This change addresses some problems with GGUF conversion including:
 * correctly naming the MoE tensors
 * correctly quantizing the nextn.eh_proj.weight MTP tensor
2026-06-01 09:43:11 -07:00
Jeffrey Morgan e7766a4a47 model: improvements to laguna-xs.2 parser/renderer (#16362) 2026-05-31 14:11:07 -07:00
Jeffrey Morgan be7de10c41 llama: handle Gemma 4 and LFM2 BOS override in llama server (#16367) 2026-05-31 14:05:39 -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
1203 changed files with 43338 additions and 432312 deletions

No files matched your search

+144 -92
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,13 +140,12 @@ 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:
# Increase pagefile to handle momentary spikes in RAM from NVCC compiles
- if: startsWith(matrix.preset, 'MLX ')
name: Increase pagefile to 200 GB
uses: al-cheb/configure-pagefile-action@v1.5
@@ -155,6 +159,15 @@ jobs:
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
@@ -203,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: |
@@ -240,73 +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 --preset "${{ matrix.preset }}" -- -l $([Environment]::ProcessorCount)
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
if ('${{ matrix.preset }}'.StartsWith('MLX ')) { cmake --install build --component MLX_VENDOR }
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'
@@ -323,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 }}
@@ -362,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*
@@ -376,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
@@ -389,31 +416,28 @@ jobs:
dist/*.ps1
dist/OllamaSetup.exe
# Pre-build each Dockerfile stage on its own runner in parallel and push the
# resulting layers to a per-stage registry cache. The downstream
# docker-build-push job then assembles cache-hit-only.
linux-depends:
strategy:
matrix:
include:
- arch: amd64
target: cpu
target: llama-server-cpu
- arch: amd64
target: cuda-12
target: llama-server-cuda_v12
- arch: amd64
target: cuda-13
target: llama-server-cuda_v13
- arch: amd64
target: mlx
- arch: amd64
target: rocm-7
target: llama-server-rocm_v7_2
- arch: amd64
target: vulkan
target: llama-server-vulkan
- arch: arm64
target: cpu
target: llama-server-cpu
- arch: arm64
target: cuda-12
target: llama-server-cuda_v12
- arch: arm64
target: cuda-13
target: llama-server-cuda_v13
- arch: arm64
target: jetpack-5
- arch: arm64
@@ -430,7 +454,6 @@ jobs:
with:
username: ${{ vars.DOCKER_USER }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
# Increase swap to handle momentary spikes in RAM from NVCC compiles
- if: matrix.target == 'mlx'
name: Increase Linux swap to 200 GB
shell: bash
@@ -459,12 +482,13 @@ jobs:
provenance: false
sbom: false
build-args: |
GOFLAGS=${{ env.GOFLAGS }}
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
GOFLAGS=${{ env.GOFLAGS }}
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
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
@@ -472,58 +496,65 @@ jobs:
# 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, and pushes the final image.
# 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=${{ vars.DOCKER_REPO }}:latest
type=registry,ref=ollama/release:cache-arm64-cpu
type=registry,ref=ollama/release:cache-arm64-cuda-12
type=registry,ref=ollama/release:cache-arm64-cuda-13
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=${{ vars.DOCKER_REPO }}:latest
type=registry,ref=ollama/release:cache-amd64-cpu
type=registry,ref=ollama/release:cache-amd64-cuda-12
type=registry,ref=ollama/release:cache-amd64-cuda-13
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-vulkan
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
type=registry,ref=ollama/release:cache-amd64-cpu
type=registry,ref=ollama/release:cache-amd64-rocm-7
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
environment: release
needs: [setup-environment, linux-depends]
@@ -556,14 +587,11 @@ jobs:
name: digest-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}
path: |
${{ runner.temp }}/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
# Re-run buildx with --target archive against buildkit's local cache to
# extract the release directory layout. All upstream stages were just
# built above, so this is a cache-hit-only pass that just writes files.
- uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.os }}/${{ matrix.arch }}
target: archive
target: ${{ matrix.archive-target }}
provenance: false
sbom: false
build-args: ${{ matrix.build-args }}
@@ -572,24 +600,33 @@ jobs:
- 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 }}.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 ;;
lib/ollama/rocm_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
esac
done
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
# rocm builds cpu + rocm libs for the container image, which
# creates a CPU-only amd64 tarball that would collide with the full
# bundle when the release job merges artifacts.
- if: matrix.suffix == '-rocm'
run: rm -f dist/${{ matrix.os }}-${{ matrix.arch }}/ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in
- run: |
@@ -665,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
@@ -0,0 +1,98 @@
name: test-darwin-xcode-pin
on:
workflow_dispatch:
push:
branches:
- test/darwin-xcode-pin
pull_request:
paths:
- '.github/workflows/test-darwin-xcode-pin.yaml'
- 'scripts/build_darwin.sh'
- 'MLX_VERSION'
- 'MLX_C_VERSION'
- 'cmake/**'
- 'x/mlxrunner/**'
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CGO_CFLAGS: '-O3'
CGO_CXXFLAGS: '-O3'
PINNED_DEVELOPER_DIR: /Applications/Xcode_26.4.1.app/Contents/Developer
jobs:
darwin-build:
runs-on: macos-26-xlarge
env:
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
steps:
- uses: actions/checkout@v4
- name: Set build environment
shell: bash
run: |
set -euo pipefail
VERSION="0.0.0-xcode-pin-${GITHUB_SHA::7}"
{
echo "VERSION=${VERSION}"
echo "GOFLAGS='-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${VERSION}\" \"-X=github.com/ollama/ollama/server.mode=release\"'"
} >>"${GITHUB_ENV}"
- name: Select Xcode 26.4.1
shell: bash
run: |
set -euo pipefail
if [ ! -d "${PINNED_DEVELOPER_DIR}" ]; then
echo "Missing ${PINNED_DEVELOPER_DIR}"
ls -1 /Applications | grep '^Xcode' || true
exit 1
fi
sudo xcode-select -s "${PINNED_DEVELOPER_DIR}"
echo "DEVELOPER_DIR=${PINNED_DEVELOPER_DIR}" >>"${GITHUB_ENV}"
sw_vers
xcodebuild -version
xcrun --sdk macosx --show-sdk-version
xcrun --find metal
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: |
go.sum
LLAMA_CPP_VERSION
MLX_VERSION
MLX_C_VERSION
- name: Build unsigned Darwin runtime
run: ./scripts/build_darwin.sh build package
- name: Verify MLX payload
shell: bash
run: |
set -euo pipefail
test -f dist/darwin/lib/ollama/mlx_metal_v3/libmlxc.dylib
test -f dist/darwin/lib/ollama/mlx_metal_v3/mlx.metallib
test -f dist/darwin/lib/ollama/mlx_metal_v4/libmlxc.dylib
test -f dist/darwin/lib/ollama/mlx_metal_v4/mlx.metallib
find dist/darwin/lib/ollama -maxdepth 3 -type f \( -name 'libmlx*.dylib' -o -name '*.metallib' \) -print
lipo -archs dist/darwin/lib/ollama/mlx_metal_v3/libmlxc.dylib
lipo -archs dist/darwin/lib/ollama/mlx_metal_v4/libmlxc.dylib
- name: Log build results
run: ls -l dist/
- uses: actions/upload-artifact@v4
with:
name: ollama-darwin-xcode-pin
path: dist/ollama-darwin.tgz
compression-level: 0
+596
View File
@@ -0,0 +1,596 @@
name: test-llamacpp-update
# PR validation artifacts from this workflow are intentionally unsigned and not
# notarized. They are for llama.cpp update testing only and must not be
# published as release artifacts.
on:
pull_request:
paths:
- 'LLAMA_CPP_VERSION'
permissions:
contents: read
env:
CGO_CFLAGS: '-O3'
CGO_CXXFLAGS: '-O3'
jobs:
setup-environment:
runs-on: ubuntu-latest
outputs:
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
VERSION: ${{ steps.goflags.outputs.VERSION }}
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
steps:
- uses: actions/checkout@v4
- name: Set environment
id: goflags
shell: bash
run: |
set -euo pipefail
VERSION="0.0.0-llamacpp-${GITHUB_SHA::7}"
{
echo "GOFLAGS='-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${VERSION}\" \"-X=github.com/ollama/ollama/server.mode=release\"'"
echo "VERSION=${VERSION}"
echo "vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION)"
} >>"${GITHUB_OUTPUT}"
darwin-build:
runs-on: macos-26-xlarge
needs: setup-environment
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: |
go.sum
LLAMA_CPP_VERSION
MLX_VERSION
MLX_C_VERSION
- name: Build unsigned Darwin runtime
run: ./scripts/build_darwin.sh build package
- name: Log build results
run: ls -l dist/
- uses: actions/upload-artifact@v4
with:
name: ollama-darwin.tgz
path: dist/ollama-darwin.tgz
compression-level: 0
# Build payload export stages independently and combine the exported
# filesystem artifacts below. This preserves parallelism without Docker
# registry credentials or oversized GitHub layer caches.
linux-payloads:
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
needs: setup-environment
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
target: publish-llama-server-cpu
payload: cpu
- arch: amd64
target: publish-llama-server-cuda_v12
payload: cuda_v12
- arch: amd64
target: publish-llama-server-cuda_v13
payload: cuda_v13
- arch: amd64
target: publish-llama-server-rocm_v7_2
payload: rocm_v7_2
- arch: amd64
target: publish-llama-server-vulkan
payload: vulkan
- arch: arm64
target: publish-llama-server-cpu
payload: cpu
- arch: arm64
target: publish-llama-server-cuda_v12
payload: cuda_v12
- arch: arm64
target: publish-llama-server-cuda_v13
payload: cuda_v13
- arch: arm64
target: publish-llama-server-cuda_jetpack5
payload: cuda_jetpack5
- arch: arm64
target: publish-llama-server-cuda_jetpack6
payload: cuda_jetpack6
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/${{ matrix.arch }}
target: ${{ matrix.target }}
provenance: false
sbom: false
build-args: |
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
outputs: type=local,dest=${{ runner.temp }}/payload
- name: Pack Linux payload
shell: bash
run: |
set -euo pipefail
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst"
- uses: actions/upload-artifact@v4
with:
name: linux-payload-${{ matrix.arch }}-${{ matrix.payload }}
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst
compression-level: 0
linux-go:
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
needs: setup-environment
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/${{ matrix.arch }}
target: publish-go
provenance: false
sbom: false
build-args: |
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
outputs: type=local,dest=${{ runner.temp }}/payload
- name: Pack Linux Go payload
shell: bash
run: |
set -euo pipefail
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst"
- uses: actions/upload-artifact@v4
with:
name: linux-payload-${{ matrix.arch }}-go
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst
compression-level: 0
# MLX payloads are intentionally excluded from this workflow; the Dockerfile
# still exposes publish-mlx for a separate MLX-specific workflow.
linux-bundles:
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
needs: [linux-payloads, linux-go]
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
pattern: linux-payload-${{ matrix.arch }}-*
path: ${{ runner.temp }}/payloads
merge-multiple: true
- name: Assemble Linux payload tree
shell: bash
run: |
set -euo pipefail
src="${{ runner.temp }}/payloads"
arch="${{ matrix.arch }}"
dest="dist/linux-${arch}"
copy_payload() {
local name="$1"
local payload="${src}/linux-payload-${arch}-${name}.tar.zst"
if [ ! -f "${payload}" ]; then
echo "missing payload ${payload}"
exit 1
fi
zstd -d <"${payload}" | tar -C "${dest}" -xf -
}
mkdir -p "${dest}"
copy_payload go
copy_payload cpu
copy_payload cuda_v12
copy_payload cuda_v13
if [ "${arch}" = "amd64" ]; then
copy_payload vulkan
copy_payload rocm_v7_2
else
copy_payload cuda_jetpack5
copy_payload cuda_jetpack6
fi
./scripts/deduplicate_cuda_libs.sh "${dest}"
- name: Verify Linux build payloads
shell: bash
run: |
set -euo pipefail
base="dist/linux-${{ matrix.arch }}"
for payload in \
"${base}/bin/ollama" \
"${base}/lib/ollama/llama-server"
do
[ -f "${payload}" ] || { echo "missing ${payload}"; exit 1; }
done
- name: Create archive input lists
shell: bash
run: |
set -euo pipefail
for COMPONENT in bin/* lib/ollama/*; do
case "${COMPONENT}" in
bin/ollama*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
lib/ollama/*.so*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
lib/ollama/cuda_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
lib/ollama/vulkan*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
lib/ollama/mlx*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/include*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
lib/ollama/cuda_jetpack5) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack5.tar.in ;;
lib/ollama/cuda_jetpack6) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack6.tar.in ;;
lib/ollama/rocm_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-rocm.tar.in ;;
esac
done
working-directory: dist/linux-${{ matrix.arch }}
- name: Log archive input lists
shell: bash
run: |
set -euo pipefail
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
echo "${ARCHIVE}"
cat "${ARCHIVE}"
done
- name: Create Linux archives
shell: bash
run: |
set -euo pipefail
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
tar c -C dist/linux-${{ matrix.arch }} -T "${ARCHIVE}" --owner 0 --group 0 | zstd -19 -T0 >"$(basename "${ARCHIVE//.*/}.tar.zst")" &
done
wait
- uses: actions/upload-artifact@v4
with:
name: ollama-linux-${{ matrix.arch }}.tar.zst
path: ollama-linux-${{ matrix.arch }}.tar.zst
compression-level: 0
- if: matrix.arch == 'amd64'
uses: actions/upload-artifact@v4
with:
name: ollama-linux-amd64-rocm.tar.zst
path: ollama-linux-amd64-rocm.tar.zst
compression-level: 0
- if: matrix.arch == 'arm64'
uses: actions/upload-artifact@v4
with:
name: ollama-linux-arm64-jetpack5.tar.zst
path: ollama-linux-arm64-jetpack5.tar.zst
compression-level: 0
- if: matrix.arch == 'arm64'
uses: actions/upload-artifact@v4
with:
name: ollama-linux-arm64-jetpack6.tar.zst
path: ollama-linux-arm64-jetpack6.tar.zst
compression-level: 0
windows-depends:
needs: setup-environment
strategy:
fail-fast: false
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"'
- '"nvcc"'
- '"cublas"'
- '"cublas_dev"'
cuda-version: '12.8'
- 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"'
- '"nvcc"'
- '"cublas"'
- '"cublas_dev"'
- '"crt"'
- '"nvvm"'
- '"nvptxcompiler"'
cuda-version: '13.0'
- os: windows
arch: amd64
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
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
steps:
- 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')
id: cache-install
uses: actions/cache/restore@v4
with:
path: |
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
C:\Program Files\AMD\ROCm
C:\VulkanSDK
key: ${{ matrix.install }}
- if: startsWith(matrix.preset, 'CUDA ')
name: Install CUDA ${{ matrix.cuda-version }}
run: |
$ErrorActionPreference = "Stop"
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
}
$cudaPath = (Resolve-Path "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\*").path
echo "$cudaPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- if: startsWith(matrix.preset, 'ROCm')
name: Install ROCm ${{ matrix.rocm-version }}
run: |
$ErrorActionPreference = "Stop"
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
Start-Process -FilePath .\install.exe -ArgumentList '-install' -NoNewWindow -Wait
}
$hipPath = (Resolve-Path "C:\Program Files\AMD\ROCm\*").path
echo "$hipPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
echo "CC=$hipPath\bin\clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
echo "CXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
echo "HIPCXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
echo "HIP_PLATFORM=amd" | Out-File -FilePath $env:GITHUB_ENV -Append
echo "CMAKE_PREFIX_PATH=$hipPath" | Out-File -FilePath $env:GITHUB_ENV -Append
- if: matrix.preset == 'Vulkan'
name: Install Vulkan
run: |
$ErrorActionPreference = "Stop"
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
Start-Process -FilePath .\install.exe -ArgumentList "-c","--am","--al","in" -NoNewWindow -Wait
}
$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: ${{ !cancelled() && matrix.preset != 'CPU' && steps.cache-install.outputs.cache-hit != 'true' }}
uses: actions/cache/save@v4
with:
path: |
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
C:\Program Files\AMD\ROCm
C:\VulkanSDK
key: ${{ matrix.install }}
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: ${{ github.workspace }}\.ccache
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
- 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'
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
./scripts/build_windows.ps1 @steps
env:
CMAKE_GENERATOR: Ninja
- 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\*
compression-level: 0
windows-build:
runs-on: windows
needs: setup-environment
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
steps:
- 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-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
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
LLAMA_CPP_VERSION
MLX_VERSION
MLX_C_VERSION
- name: Verify gcc is actually clang
run: |
$ErrorActionPreference='Continue'
$version=& gcc -v 2>&1
$version=$version -join "`n"
echo "gcc is $version"
if ($version -notmatch 'clang') {
echo "ERROR: GCC must be clang for proper utf16 handling"
exit 1
}
$ErrorActionPreference='Stop'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Build Windows binaries and app launchers
run: ./scripts/build_windows.ps1 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-windows-amd64
path: dist\*
compression-level: 0
windows-package:
runs-on: windows
needs: [setup-environment, windows-build, windows-depends]
env:
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache-dependency-path: |
go.sum
LLAMA_CPP_VERSION
MLX_VERSION
MLX_C_VERSION
- uses: actions/download-artifact@v4
with:
pattern: depends-windows*
path: dist
merge-multiple: true
- uses: actions/download-artifact@v4
with:
pattern: build-windows*
path: dist
merge-multiple: true
- name: Copy unsigned install script
run: Copy-Item -Path .\scripts\install.ps1 -Destination .\dist\install.ps1 -ErrorAction Stop
- 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
- name: Build unsigned Windows installer and zips
run: ./scripts/build_windows.ps1 deps installer zip
- name: Log contents after build
run: |
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
- name: Verify Windows package outputs
shell: bash
run: |
set -euo pipefail
for payload in \
dist/ollama-windows-amd64.zip \
dist/ollama-windows-arm64.zip \
dist/ollama-windows-amd64-rocm.zip \
dist/OllamaSetup.exe \
dist/install.ps1
do
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
done
- uses: actions/upload-artifact@v4
with:
name: ollama-windows-amd64.zip
path: dist/ollama-windows-amd64.zip
compression-level: 0
- uses: actions/upload-artifact@v4
with:
name: ollama-windows-arm64.zip
path: dist/ollama-windows-arm64.zip
compression-level: 0
- uses: actions/upload-artifact@v4
with:
name: ollama-windows-amd64-rocm.zip
path: dist/ollama-windows-amd64-rocm.zip
compression-level: 0
- uses: actions/upload-artifact@v4
with:
name: OllamaSetup.exe
path: dist/OllamaSetup.exe
compression-level: 0
- uses: actions/upload-artifact@v4
with:
name: install.ps1
path: dist/install.ps1
compression-level: 0
+152 -37
View File
@@ -23,7 +23,7 @@ jobs:
outputs:
changed: ${{ steps.changes.outputs.changed }}
app_changed: ${{ steps.changes.outputs.app_changed }}
vendorsha: ${{ steps.changes.outputs.vendorsha }}
enginehash: ${{ steps.changes.outputs.enginehash }}
steps:
- uses: actions/checkout@v4
with:
@@ -38,9 +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 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 vendorsha=$(make -f Makefile.sync print-base) | 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]
@@ -49,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 -DMLX_CUDA_ARCHITECTURES=80-virtual -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 }}
@@ -82,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
@@ -96,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 }}" -- -l $(nproc)
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'
@@ -114,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"'
@@ -127,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 -DMLX_CUDA_ARCHITECTURES=80-virtual'
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"'
@@ -203,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'
@@ -232,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 --preset "${{ matrix.preset }}" -- -l $([Environment]::ProcessorCount)
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:
@@ -266,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'
@@ -280,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 ./...
@@ -294,12 +418,3 @@ jobs:
- 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
View File
@@ -0,0 +1,21 @@
# AGENTS.md
## Building
For a full build from the repository root:
```sh
cmake -B build .
cmake --build build --parallel 8
./ollama serve
```
For quick Go-only iteration against an existing native payload:
```sh
go build .
go run . serve
```
See `docs/development.md` for prerequisites, platform notes, GPU backends, and
the full development workflow.
+3
View File
@@ -0,0 +1,3 @@
# CLAUDE.md
See `AGENTS.md` for the shared agent instructions for this repository.
+21 -332
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,314 +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()
# Split mlx/mlxc libraries from runtime deps to avoid stripping deps
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)
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_VENDOR)
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": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-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"
}
]
}
+185 -101
View File
@@ -37,116 +37,171 @@ 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 scratch AS publish-llama-server-cpu
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
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 scratch AS publish-llama-server-cuda_v12
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
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 scratch AS publish-llama-server-cuda_v13
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
FROM rocm-7-deps AS llama-server-rocm_v7_2
ENV CC=clang CXX=clang++
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 scratch AS publish-llama-server-rocm_v7_2
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
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
FROM scratch AS publish-llama-server-vulkan
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
#
# 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 scratch AS publish-llama-server-cuda_jetpack5
COPY --from=jetpack-5 dist/lib/ollama /lib/ollama/
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
FROM scratch AS publish-llama-server-cuda_jetpack6
COPY --from=jetpack-6 dist/lib/ollama /lib/ollama/
#
# MLX stage
#
FROM base AS mlx
ARG CUDA13VERSION=13.0
# OLLAMA_MLX_BUILD_JOBS empty -> ninja gates by load average (-l $(nproc))
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 \
@@ -157,7 +212,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 .
@@ -173,10 +228,15 @@ 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 -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" \
&& cmake --build --preset 'MLX CUDA 13' -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}} \
&& cmake --install build --component MLX --strip \
&& cmake --install build --component MLX_VENDOR
&& 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}}
FROM scratch AS publish-mlx
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
#
# Go build
#
FROM base AS build
WORKDIR /go/src/github.com/ollama/ollama
@@ -194,38 +254,62 @@ ENV CGO_CXXFLAGS="${CGO_CXXFLAGS}"
RUN --mount=type=cache,target=/root/.cache/go-build \
go build -trimpath -buildmode=pie -o /bin/ollama .
FROM scratch AS publish-go
COPY --from=build /bin/ollama /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 @@
b9672
+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)
+5 -5
View File
@@ -77,10 +77,10 @@ ollama launch openclaw
### Chat with a model
Run and chat with [Gemma 3](https://ollama.com/library/gemma3):
Run and chat with [Gemma 4](https://ollama.com/library/gemma4):
```
ollama run gemma3
ollama run gemma4
```
See [ollama.com/library](https://ollama.com/library) for the full list.
@@ -93,7 +93,7 @@ Ollama has a REST API for running and managing models.
```
curl http://localhost:11434/api/chat -d '{
"model": "gemma3",
"model": "gemma4",
"messages": [{
"role": "user",
"content": "Why is the sky blue?"
@@ -113,7 +113,7 @@ pip install ollama
```python
from ollama import chat
response = chat(model='gemma3', messages=[
response = chat(model='gemma4', messages=[
{
'role': 'user',
'content': 'Why is the sky blue?',
@@ -132,7 +132,7 @@ npm i ollama
import ollama from "ollama";
const response = await ollama.chat({
model: "gemma3",
model: "gemma4",
messages: [{ role: "user", content: "Why is the sky blue?" }],
});
console.log(response.message.content);
+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
}
+42 -17
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"`
@@ -1049,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:
@@ -1089,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,
},
}
}
@@ -1297,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
+10 -50
View File
@@ -14,6 +14,7 @@
#define MyAppPublisher "Ollama"
#define MyAppURL "https://ollama.com/"
#define MyAppExeName "ollama app.exe"
#define LlamaServerExeName "llama-server.exe"
#define MyIcon ".\assets\app.ico"
[Setup]
@@ -90,9 +91,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 +103,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 +120,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]
@@ -131,6 +127,7 @@ Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeNam
; Filename: "{cmd}"; Parameters: "/C ""taskkill /im ollama.exe /f /t"; Flags: runhidden
Filename: "taskkill"; Parameters: "/im ""{#MyAppExeName}"" /f /t"; Flags: runhidden
Filename: "taskkill"; Parameters: "/im ""ollama.exe"" /f /t"; Flags: runhidden
Filename: "taskkill"; Parameters: "/im ""{#LlamaServerExeName}"" /f /t"; Flags: runhidden
; HACK! need to give the server and app enough time to exit
; TODO - convert this to a Pascal code script so it waits until they're no longer running, then completes
Filename: "{cmd}"; Parameters: "/c timeout 5"; Flags: runhidden
@@ -184,46 +181,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;
@@ -370,5 +327,8 @@ procedure TaskKill(FileName: String);
var
ResultCode: Integer;
begin
Exec('taskkill.exe', '/f /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
Exec('taskkill.exe', '/f /t /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
if FileName <> '{#LlamaServerExeName}' then begin
Exec('taskkill.exe', '/f /t /im "{#LlamaServerExeName}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
end;
end;
+1 -1
View File
@@ -164,7 +164,7 @@ func reapServers() error {
continue
}
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
cmd := exec.Command("taskkill", "/F", "/T", "/PID", pidStr)
if err := cmd.Run(); err != nil {
slog.Warn("failed to kill ollama process", "pid", pid, "err", err)
}
+4
View File
@@ -563,6 +563,10 @@ func (b *BrowserOpen) Execute(ctx context.Context, args map[string]any) (any, st
return b.state.Data, pageText, nil
}
if !allowedDirectURL(ctx, url) {
return nil, "", fmt.Errorf("direct URL open is only allowed for URLs provided by the user")
}
// Page not in cache, need to crawl it
if b.crawlPage == nil {
b.crawlPage = &BrowserCrawler{}
+21
View File
@@ -65,6 +65,27 @@ func TestBrowserOpen_UseCacheByURL(t *testing.T) {
}
}
func TestBrowserOpen_RejectsUncachedDirectURL(t *testing.T) {
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
bo := NewBrowserOpen(b)
_, _, err := bo.Execute(t.Context(), map[string]any{"id": "https://attacker.example/?data=secret"})
if err == nil || !strings.Contains(err.Error(), "only allowed for URLs provided by the user") {
t.Fatalf("expected direct URL rejection, got %v", err)
}
}
func TestDirectURLsFromText_AllowsExactUserURLsOnly(t *testing.T) {
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://example.com/article?q=1 please")
if !allowedDirectURL(ctx, "https://example.com/article?q=1") {
t.Fatal("expected exact user-provided URL to be allowed")
}
if allowedDirectURL(ctx, "https://example.com/article?q=secret") {
t.Fatal("did not expect modified URL to be allowed")
}
}
func TestDisplayPage_InvalidLoc(t *testing.T) {
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
p := makeTestPage("https://example.com/x")
+61
View File
@@ -0,0 +1,61 @@
//go:build windows || darwin
package tools
import (
"context"
"regexp"
"strings"
)
type directURLContextKey struct{}
var directURLPattern = regexp.MustCompile("https?://[^\\s<>\"'`]+")
func WithAllowedDirectURLs(ctx context.Context, text string) context.Context {
allowed := make(map[string]struct{})
for _, match := range directURLPattern.FindAllString(text, -1) {
addAllowedDirectURLToMap(allowed, match)
}
return context.WithValue(ctx, directURLContextKey{}, allowed)
}
func addAllowedDirectURL(ctx context.Context, raw string) {
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
addAllowedDirectURLToMap(allowed, raw)
}
func addAllowedDirectURLToMap(allowed map[string]struct{}, raw string) {
if allowed == nil {
return
}
raw = cleanDirectURL(raw)
if raw == "" {
return
}
allowed[raw] = struct{}{}
}
func allowedDirectURL(ctx context.Context, raw string) bool {
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
cleaned := cleanDirectURL(raw)
if cleaned == "" || cleaned != raw {
return false
}
_, ok := allowed[cleaned]
return ok
}
func cleanDirectURL(raw string) string {
raw = strings.TrimSpace(raw)
raw = strings.TrimRight(raw, ".,;:!?)]}")
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
return ""
}
return raw
}
+21
View File
@@ -0,0 +1,21 @@
//go:build windows || darwin
package tools
import "testing"
func TestDirectURLsFromText_RejectsChangedToolArgument(t *testing.T) {
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://attacker.example/x")
if allowedDirectURL(ctx, "https://attacker.example/x!!!!") {
t.Fatal("expected changed tool argument to be rejected")
}
}
func TestDirectURLsFromText_ExtractsMarkdownCodeSpanURL(t *testing.T) {
ctx := WithAllowedDirectURLs(t.Context(), "summarize `https://example.com/privacy`")
if !allowedDirectURL(ctx, "https://example.com/privacy") {
t.Fatal("expected URL wrapped in backticks to be allowed")
}
}
+6
View File
@@ -67,11 +67,17 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin
if !ok || strings.TrimSpace(urlStr) == "" {
return nil, "", fmt.Errorf("url must be a non-empty string")
}
if !allowedDirectURL(ctx, urlStr) {
return nil, "", fmt.Errorf("web fetch is only allowed for URLs provided by the user")
}
result, err := performWebFetch(ctx, urlStr)
if err != nil {
return nil, "", err
}
for _, link := range result.Links {
addAllowedDirectURL(ctx, link)
}
return result, "", nil
}
+3
View File
@@ -88,6 +88,9 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri
if err != nil {
return nil, "", err
}
for _, result := range result.Results {
addAllowedDirectURL(ctx, result.URL)
}
return result, "", nil
}
@@ -0,0 +1,61 @@
import { renderToStaticMarkup } from "react-dom/server";
import type React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
type MockStreamdownProps = {
children?: React.ReactNode;
components: {
img: React.ComponentType<React.ImgHTMLAttributes<HTMLImageElement>>;
};
rehypePlugins?: unknown[];
};
const streamdownMock = vi.hoisted(() =>
vi.fn((props: MockStreamdownProps) => props.children),
);
vi.mock("streamdown", () => ({
Streamdown: streamdownMock,
defaultRehypePlugins: {
katex: "katex",
raw: "raw",
},
defaultRemarkPlugins: {
gfm: "gfm",
math: "math",
},
}));
import StreamingMarkdownContent from "./StreamingMarkdownContent";
describe("StreamingMarkdownContent", () => {
beforeEach(() => {
streamdownMock.mockClear();
});
it("does not enable raw HTML parsing", () => {
renderToStaticMarkup(
<StreamingMarkdownContent content="<iframe></iframe>" />,
);
const props = streamdownMock.mock.calls[0][0];
expect(props.rehypePlugins).toEqual(["katex"]);
expect(props.rehypePlugins).not.toContain("raw");
});
it("does not render markdown image src values", () => {
renderToStaticMarkup(
<StreamingMarkdownContent content="![secret](https://attacker.example/pixel?data=secret)" />,
);
const props = streamdownMock.mock.calls[0][0];
const Img = props.components.img;
const html = renderToStaticMarkup(
<Img alt="secret" src="https://attacker.example/pixel?data=secret" />,
);
expect(html).not.toContain("<img");
expect(html).not.toContain("attacker.example");
expect(html).toContain("secret");
});
});
@@ -1,5 +1,9 @@
import React from "react";
import { Streamdown, defaultRemarkPlugins } from "streamdown";
import {
Streamdown,
defaultRehypePlugins,
defaultRemarkPlugins,
} from "streamdown";
import remarkCitationParser from "@/utils/remarkCitationParser";
import CopyButton from "./CopyButton";
import type { BundledLanguage } from "shiki";
@@ -29,6 +33,8 @@ const extractText = (node: React.ReactNode): string => {
return "";
};
const safeRehypePlugins = [defaultRehypePlugins.katex];
const CodeBlock = React.memo(
({ children }: React.HTMLAttributes<HTMLPreElement>) => {
// Extract code and language from children
@@ -210,9 +216,12 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
<Streamdown
parseIncompleteMarkdown={isStreaming}
isAnimating={isStreaming}
rehypePlugins={safeRehypePlugins}
remarkPlugins={remarkPlugins}
controls={false}
components={{
img: ({ alt }: React.ImgHTMLAttributes<HTMLImageElement>) =>
alt ? <span>{alt}</span> : null,
pre: CodeBlock,
table: ({
children,
+13
View File
@@ -574,6 +574,18 @@ func (s *Server) getError(err error) responses.ErrorEvent {
}
}
func userMessageText(messages []store.Message) string {
var b strings.Builder
for _, message := range messages {
if message.Role != "user" {
continue
}
b.WriteString(message.Content)
b.WriteByte('\n')
}
return b.String()
}
func (s *Server) browserState(chat *store.Chat) (*responses.BrowserStateData, bool) {
if len(chat.BrowserState) > 0 {
var st responses.BrowserStateData
@@ -839,6 +851,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) error {
// Note: Skip agent/tools mode if user has attachments, as the agent doesn't handle file attachments properly
registry := tools.NewRegistry()
var browser *tools.Browser
ctx = tools.WithAllowedDirectURLs(ctx, userMessageText(chat.Messages))
if !hasAttachments {
WebSearchEnabled := req.WebSearch != nil && *req.WebSearch
+692
View File
@@ -0,0 +1,692 @@
# 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 GGML_CUDA_NO_PEER_COPY)
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 ")
+121 -17
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
})
}
@@ -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"]}
@@ -2485,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:
@@ -2509,6 +2612,7 @@ func NewCLI() *cobra.Command {
copyCmd,
deleteCmd,
runnerCmd,
gpuDiscoverCmd,
launch.LaunchCmd(checkServerHeartbeat, runInteractiveTUI),
)
+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)
}
}
+179 -21
View File
@@ -6,38 +6,88 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
)
const clineLaunchProvider = "ollama"
// Cline implements Runner and Editor for the Cline CLI integration
type Cline struct{}
func (c *Cline) String() string { return "Cline" }
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")
bin, err := ensureClineInstalled()
if err != nil {
return err
}
cmd := exec.Command("cline", args...)
launchArgs := clineLaunchArgs(model, args)
cmd := exec.Command(bin, launchArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
func ensureClineInstalled() (string, error) {
if _, err := exec.LookPath("cline"); err == nil {
return "cline", nil
}
if _, err := exec.LookPath("npm"); err != nil {
return "", fmt.Errorf("cline is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch cline")
}
ok, err := ConfirmPrompt("Cline is not installed. Install with npm?")
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("cline installation cancelled")
}
fmt.Fprintf(os.Stderr, "\nInstalling Cline...\n")
cmd := exec.Command("npm", "install", "-g", "cline@latest")
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to install cline: %w", err)
}
if _, err := exec.LookPath("cline"); err != nil {
return "", fmt.Errorf("cline was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
}
fmt.Fprintf(os.Stderr, "%sCline installed successfully%s\n\n", ansiGreen, ansiReset)
return "cline", nil
}
func clineLaunchArgs(model string, extra []string) []string {
return extra
}
func (c *Cline) Paths() []string {
home, err := os.UserHomeDir()
if err != nil {
return nil
}
p := filepath.Join(home, ".cline", "data", "globalState.json")
if _, err := os.Stat(p); err == nil {
return []string{p}
var paths []string
for _, p := range []string{
clineProvidersPath(home),
clineLegacyGlobalStatePath(home),
} {
if _, err := os.Stat(p); err == nil {
paths = append(paths, p)
}
}
return nil
return paths
}
func (c *Cline) Edit(models []LaunchModel) error {
@@ -50,26 +100,113 @@ func (c *Cline) Edit(models []LaunchModel) error {
return err
}
configPath := filepath.Join(home, ".cline", "data", "globalState.json")
providersPath := clineProvidersPath(home)
legacyPath := clineLegacyGlobalStatePath(home)
providersConfig, err := readClineConfig(providersPath)
if err != nil {
return err
}
legacyConfig, err := readClineConfig(legacyPath)
if err != nil {
return err
}
if err := writeClineProvidersConfig(providersPath, providersConfig, models[0].Name); err != nil {
return err
}
return writeClineLegacyGlobalState(legacyPath, legacyConfig, models[0].Name)
}
func clineProvidersPath(home string) string {
return filepath.Join(home, ".cline", "data", "settings", "providers.json")
}
func clineLegacyGlobalStatePath(home string) string {
return filepath.Join(home, ".cline", "data", "globalState.json")
}
func clineOllamaRootURL() string {
return strings.TrimRight(envconfig.ConnectableHost().String(), "/")
}
func clineProviderBaseURL() string {
return clineOllamaRootURL() + "/v1"
}
func readClineConfig(configPath string) (map[string]any, error) {
config := make(map[string]any)
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config: %w, at: %s", err, configPath)
}
} else if !os.IsNotExist(err) {
return nil, err
}
return config, nil
}
func writeClineProvidersConfig(configPath string, config map[string]any, model string) error {
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
config := make(map[string]any)
if data, err := os.ReadFile(configPath); err == nil {
if err := json.Unmarshal(data, &config); err != nil {
return fmt.Errorf("failed to parse config: %w, at: %s", err, configPath)
}
providers, _ := config["providers"].(map[string]any)
if providers == nil {
providers = make(map[string]any)
}
// Set Ollama as the provider for both act and plan modes
baseURL := envconfig.Host().String()
provider, _ := providers[clineLaunchProvider].(map[string]any)
if provider == nil {
provider = make(map[string]any)
}
settings, _ := provider["settings"].(map[string]any)
if settings == nil {
settings = make(map[string]any)
}
baseURL := clineProviderBaseURL()
previousModel, _ := settings["model"].(string)
previousBaseURL, _ := settings["baseUrl"].(string)
previousTokenSource, _ := provider["tokenSource"].(string)
settings["provider"] = clineLaunchProvider
settings["model"] = model
settings["baseUrl"] = baseURL
delete(settings, "apiKey")
provider["settings"] = settings
if previousModel != model || previousBaseURL != baseURL || previousTokenSource != "manual" {
provider["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
} else if _, ok := provider["updatedAt"].(string); !ok {
provider["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
}
provider["tokenSource"] = "manual"
providers[clineLaunchProvider] = provider
config["version"] = float64(1)
config["lastUsedProvider"] = clineLaunchProvider
config["providers"] = providers
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, data, "cline")
}
func writeClineLegacyGlobalState(configPath string, config map[string]any, model string) error {
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
baseURL := clineOllamaRootURL()
config["ollamaBaseUrl"] = baseURL
config["actModeApiProvider"] = "ollama"
config["actModeOllamaModelId"] = models[0].Name
config["actModeApiProvider"] = clineLaunchProvider
config["actModeOllamaModelId"] = model
config["actModeOllamaBaseUrl"] = baseURL
config["planModeApiProvider"] = "ollama"
config["planModeOllamaModelId"] = models[0].Name
config["planModeApiProvider"] = clineLaunchProvider
config["planModeOllamaModelId"] = model
config["planModeOllamaBaseUrl"] = baseURL
config["welcomeViewCompleted"] = true
@@ -87,12 +224,18 @@ func (c *Cline) Models() []string {
return nil
}
config, err := fileutil.ReadJSON(filepath.Join(home, ".cline", "data", "globalState.json"))
if model := clineProviderModel(home); model != "" {
return []string{model}
}
config, err := fileutil.ReadJSON(clineLegacyGlobalStatePath(home))
if err != nil {
return nil
}
if config["actModeApiProvider"] != "ollama" {
switch config["actModeApiProvider"] {
case "ollama":
default:
return nil
}
@@ -102,3 +245,18 @@ func (c *Cline) Models() []string {
}
return []string{modelID}
}
func clineProviderModel(home string) string {
config, err := fileutil.ReadJSON(clineProvidersPath(home))
if err != nil {
return ""
}
if config["lastUsedProvider"] != clineLaunchProvider {
return ""
}
providers, _ := config["providers"].(map[string]any)
provider, _ := providers[clineLaunchProvider].(map[string]any)
settings, _ := provider["settings"].(map[string]any)
model, _ := settings["model"].(string)
return model
}
+262 -4
View File
@@ -2,8 +2,11 @@ package launch
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
@@ -25,6 +28,55 @@ func TestClineIntegration(t *testing.T) {
})
}
func TestEnsureClineInstalled(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
clinePath := filepath.Join(tmpDir, "cline")
npmScript := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" > "$HOME/npm-calls.log"
/bin/cat > %q <<'EOF'
#!/bin/sh
exit 0
EOF
/bin/chmod +x %q
exit 0
`, clinePath, clinePath)
if err := os.WriteFile(filepath.Join(tmpDir, "npm"), []byte(npmScript), 0o755); err != nil {
t.Fatal(err)
}
oldConfirmPrompt := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
if prompt != "Cline is not installed. Install with npm?" {
t.Fatalf("unexpected prompt: %q", prompt)
}
return true, nil
}
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
bin, err := ensureClineInstalled()
if err != nil {
t.Fatalf("ensureClineInstalled() error = %v", err)
}
if bin != "cline" {
t.Fatalf("ensureClineInstalled() bin = %q, want %q", bin, "cline")
}
data, err := os.ReadFile(filepath.Join(tmpDir, "npm-calls.log"))
if err != nil {
t.Fatal(err)
}
if got := strings.TrimSpace(string(data)); got != "install -g cline@latest" {
t.Fatalf("npm args = %q, want %q", got, "install -g cline@latest")
}
}
func TestClineEdit(t *testing.T) {
c := &Cline{}
tmpDir := t.TempDir()
@@ -32,6 +84,7 @@ func TestClineEdit(t *testing.T) {
configDir := filepath.Join(tmpDir, ".cline", "data")
configPath := filepath.Join(configDir, "globalState.json")
providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json")
readConfig := func() map[string]any {
data, _ := os.ReadFile(configPath)
@@ -40,6 +93,13 @@ func TestClineEdit(t *testing.T) {
return config
}
readProvidersConfig := func() map[string]any {
data, _ := os.ReadFile(providersPath)
var config map[string]any
json.Unmarshal(data, &config)
return config
}
t.Run("creates config from scratch", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
@@ -48,26 +108,56 @@ func TestClineEdit(t *testing.T) {
}
config := readConfig()
if config["actModeApiProvider"] != "ollama" {
t.Errorf("actModeApiProvider = %v, want ollama", config["actModeApiProvider"])
if config["actModeApiProvider"] != clineLaunchProvider {
t.Errorf("actModeApiProvider = %v, want %s", config["actModeApiProvider"], clineLaunchProvider)
}
if config["actModeOllamaModelId"] != "kimi-k2.5:cloud" {
t.Errorf("actModeOllamaModelId = %v, want kimi-k2.5:cloud", config["actModeOllamaModelId"])
}
if config["planModeApiProvider"] != "ollama" {
t.Errorf("planModeApiProvider = %v, want ollama", config["planModeApiProvider"])
if config["actModeOllamaBaseUrl"] != "http://127.0.0.1:11434" {
t.Errorf("actModeOllamaBaseUrl = %v, want http://127.0.0.1:11434", config["actModeOllamaBaseUrl"])
}
if config["planModeApiProvider"] != clineLaunchProvider {
t.Errorf("planModeApiProvider = %v, want %s", config["planModeApiProvider"], clineLaunchProvider)
}
if config["planModeOllamaModelId"] != "kimi-k2.5:cloud" {
t.Errorf("planModeOllamaModelId = %v, want kimi-k2.5:cloud", config["planModeOllamaModelId"])
}
if config["planModeOllamaBaseUrl"] != "http://127.0.0.1:11434" {
t.Errorf("planModeOllamaBaseUrl = %v, want http://127.0.0.1:11434", config["planModeOllamaBaseUrl"])
}
if config["ollamaBaseUrl"] != "http://127.0.0.1:11434" {
t.Errorf("ollamaBaseUrl = %v, want http://127.0.0.1:11434", config["ollamaBaseUrl"])
}
if config["welcomeViewCompleted"] != true {
t.Errorf("welcomeViewCompleted = %v, want true", config["welcomeViewCompleted"])
}
providersConfig := readProvidersConfig()
if providersConfig["lastUsedProvider"] != clineLaunchProvider {
t.Errorf("lastUsedProvider = %v, want %s", providersConfig["lastUsedProvider"], clineLaunchProvider)
}
providers, _ := providersConfig["providers"].(map[string]any)
provider, _ := providers[clineLaunchProvider].(map[string]any)
if provider["updatedAt"] == "" {
t.Errorf("updatedAt = %v, want timestamp", provider["updatedAt"])
}
settings, _ := provider["settings"].(map[string]any)
if settings["model"] != "kimi-k2.5:cloud" {
t.Errorf("settings.model = %v, want kimi-k2.5:cloud", settings["model"])
}
if _, ok := settings["apiKey"]; ok {
t.Errorf("settings.apiKey = %v, want omitted for local Ollama", settings["apiKey"])
}
if settings["baseUrl"] != "http://127.0.0.1:11434/v1" {
t.Errorf("settings.baseUrl = %v, want http://127.0.0.1:11434/v1", settings["baseUrl"])
}
})
t.Run("preserves existing fields", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
os.MkdirAll(configDir, 0o755)
os.MkdirAll(filepath.Dir(providersPath), 0o755)
existing := map[string]any{
"remoteRulesToggles": map[string]any{},
@@ -77,6 +167,21 @@ func TestClineEdit(t *testing.T) {
data, _ := json.Marshal(existing)
os.WriteFile(configPath, data, 0o644)
existingProviders := map[string]any{
"customRoot": "keep-me-too",
"providers": map[string]any{
clineLaunchProvider: map[string]any{
"updatedAt": "2026-05-29T16:56:46.111Z",
"settings": map[string]any{
"apiKey": "bad-migrated-key",
"timeout": float64(30000),
},
},
},
}
data, _ = json.Marshal(existingProviders)
os.WriteFile(providersPath, data, 0o644)
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
t.Fatal(err)
}
@@ -88,6 +193,75 @@ func TestClineEdit(t *testing.T) {
if config["actModeOllamaModelId"] != "glm-5:cloud" {
t.Errorf("actModeOllamaModelId = %v, want glm-5:cloud", config["actModeOllamaModelId"])
}
providersConfig := readProvidersConfig()
if providersConfig["customRoot"] != "keep-me-too" {
t.Errorf("customRoot was not preserved")
}
providers, _ := providersConfig["providers"].(map[string]any)
provider, _ := providers[clineLaunchProvider].(map[string]any)
if provider["updatedAt"] == "2026-05-29T16:56:46.111Z" {
t.Errorf("updatedAt = %v, want refreshed timestamp after provider change", provider["updatedAt"])
}
settings, _ := provider["settings"].(map[string]any)
if settings["timeout"] != float64(30000) {
t.Errorf("settings.timeout = %v, want 30000", settings["timeout"])
}
if _, ok := settings["apiKey"]; ok {
t.Errorf("settings.apiKey = %v, want omitted for local Ollama", settings["apiKey"])
}
if settings["model"] != "glm-5:cloud" {
t.Errorf("settings.model = %v, want glm-5:cloud", settings["model"])
}
if settings["baseUrl"] != "http://127.0.0.1:11434/v1" {
t.Errorf("settings.baseUrl = %v, want http://127.0.0.1:11434/v1", settings["baseUrl"])
}
})
t.Run("validates both configs before writing providers config", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
os.MkdirAll(configDir, 0o755)
os.WriteFile(configPath, []byte("{not json"), 0o644)
err := c.Edit(testLaunchModels("kimi-k2.5:cloud"))
if err == nil {
t.Fatal("expected invalid legacy config error")
}
if _, statErr := os.Stat(providersPath); !os.IsNotExist(statErr) {
t.Fatalf("providers config should not be written when legacy config is invalid, stat err = %v", statErr)
}
})
t.Run("preserves updatedAt when provider settings are unchanged", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
os.MkdirAll(filepath.Dir(providersPath), 0o755)
existingProviders := map[string]any{
"providers": map[string]any{
clineLaunchProvider: map[string]any{
"updatedAt": "2026-05-29T16:56:46.111Z",
"tokenSource": "manual",
"settings": map[string]any{
"provider": clineLaunchProvider,
"model": "kimi-k2.5:cloud",
"baseUrl": "http://127.0.0.1:11434/v1",
},
},
},
}
data, _ := json.Marshal(existingProviders)
os.WriteFile(providersPath, data, 0o644)
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
t.Fatal(err)
}
providersConfig := readProvidersConfig()
providers, _ := providersConfig["providers"].(map[string]any)
provider, _ := providers[clineLaunchProvider].(map[string]any)
if provider["updatedAt"] != "2026-05-29T16:56:46.111Z" {
t.Errorf("updatedAt = %v, want preserved timestamp", provider["updatedAt"])
}
})
t.Run("updates model on re-edit", func(t *testing.T) {
@@ -142,6 +316,7 @@ func TestClineModels(t *testing.T) {
configDir := filepath.Join(tmpDir, ".cline", "data")
configPath := filepath.Join(configDir, "globalState.json")
providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json")
t.Run("returns nil when no config", func(t *testing.T) {
if models := c.Models(); models != nil {
@@ -177,6 +352,55 @@ func TestClineModels(t *testing.T) {
t.Errorf("Models() = %v, want [kimi-k2.5:cloud]", models)
}
})
t.Run("prefers CLI provider config", func(t *testing.T) {
os.MkdirAll(filepath.Dir(providersPath), 0o755)
config := map[string]any{
"lastUsedProvider": clineLaunchProvider,
"providers": map[string]any{
clineLaunchProvider: map[string]any{
"settings": map[string]any{
"model": "glm-5:cloud",
},
},
},
}
data, _ := json.Marshal(config)
os.WriteFile(providersPath, data, 0o644)
models := c.Models()
if len(models) != 1 || models[0] != "glm-5:cloud" {
t.Errorf("Models() = %v, want [glm-5:cloud]", models)
}
})
t.Run("ignores stale CLI provider config when ollama is not active", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
os.MkdirAll(configDir, 0o755)
os.MkdirAll(filepath.Dir(providersPath), 0o755)
legacyConfig := map[string]any{
"actModeApiProvider": "anthropic",
"actModeOllamaModelId": "legacy-ollama-model",
}
data, _ := json.Marshal(legacyConfig)
os.WriteFile(configPath, data, 0o644)
providerConfig := map[string]any{
"lastUsedProvider": "openai",
"providers": map[string]any{
clineLaunchProvider: map[string]any{
"settings": map[string]any{
"model": "stale-ollama-model",
},
},
},
}
data, _ = json.Marshal(providerConfig)
os.WriteFile(providersPath, data, 0o644)
if models := c.Models(); models != nil {
t.Errorf("Models() = %v, want nil", models)
}
})
}
func TestClinePaths(t *testing.T) {
@@ -201,4 +425,38 @@ func TestClinePaths(t *testing.T) {
t.Errorf("Paths() = %v, want [%s]", paths, configPath)
}
})
t.Run("returns both paths when both configs exist", func(t *testing.T) {
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
legacyPath := clineLegacyGlobalStatePath(tmpDir)
providersPath := clineProvidersPath(tmpDir)
os.MkdirAll(filepath.Dir(legacyPath), 0o755)
os.MkdirAll(filepath.Dir(providersPath), 0o755)
os.WriteFile(legacyPath, []byte("{}"), 0o644)
os.WriteFile(providersPath, []byte("{}"), 0o644)
paths := c.Paths()
want := []string{providersPath, legacyPath}
if len(paths) != len(want) {
t.Fatalf("Paths() = %v, want %v", paths, want)
}
for i := range want {
if paths[i] != want[i] {
t.Fatalf("Paths() = %v, want %v", paths, want)
}
}
})
}
func TestClineLaunchArgs(t *testing.T) {
got := clineLaunchArgs("kimi-k2.5:cloud", []string{"--json", "hello"})
want := []string{"--json", "hello"}
if len(got) != len(want) {
t.Fatalf("args length = %d, want %d: %v", len(got), len(want), got)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("args[%d] = %q, want %q; got %v", i, got[i], want[i], got)
}
}
}
+238 -130
View File
@@ -24,6 +24,7 @@ const (
codexProfileName = "ollama-launch"
codexProviderName = "Ollama"
codexFallbackContextWindow = 128_000
codexRestoreSuccess = "Codex launch configuration removed."
codexRootProfileKey = "profile"
codexRootModelKey = "model"
@@ -31,16 +32,20 @@ const (
codexRootModelCatalogJSONKey = "model_catalog_json"
)
func (c *Codex) args(model, modelCatalogPath string, extra []string) []string {
func (c *Codex) args(model, modelCatalogPath string, extra []string) ([]string, error) {
if err := codexValidateExtraArgs(extra); err != nil {
return nil, err
}
args := []string{"--profile", codexProfileName}
if modelCatalogPath != "" {
args = append(args, "-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
for _, override := range codexManagedConfigOverrides(modelCatalogPath) {
args = append(args, "-c", override)
}
if model != "" {
args = append(args, "-m", model)
}
args = append(args, extra...)
return args
return args, nil
}
func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
@@ -57,7 +62,12 @@ func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
return fmt.Errorf("failed to configure codex: %w", err)
}
cmd := exec.Command("codex", c.args(model, catalogPath, args)...)
codexArgs, err := c.args(model, catalogPath, args)
if err != nil {
return fmt.Errorf("failed to configure codex: %w", err)
}
cmd := exec.Command("codex", codexArgs...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -67,8 +77,134 @@ func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
return cmd.Run()
}
// ensureCodexConfig writes a Codex profile and model catalog so Codex uses the
// local Ollama server and has model metadata available.
func (c *Codex) Restore() error {
configPath, err := codexConfigPath()
if err != nil {
return err
}
if err := removeCodexProfileConfig(); err != nil {
return codexRestoreFailure(configPath, err)
}
if err := removeCodexModelCatalogIfUnused(configPath); err != nil {
return codexRestoreFailure(configPath, err)
}
return nil
}
func (c *Codex) RestoreSuccessMessage() string {
return codexRestoreSuccess
}
func (c *Codex) SkipRestoreInstallCheck() bool {
return true
}
func codexRestoreFailure(configPath string, err error) error {
return fmt.Errorf("restore Codex config: %w\n\nRestore did not complete. Check these files before retrying:\n Codex config: %s\n CLI profile: %s\n CLI model catalog: %s\n Backups: %s",
err,
configPath,
codexProfileConfigPathForConfig(configPath),
codexModelCatalogPathForConfig(configPath),
fileutil.BackupDir(),
)
}
func removeCodexProfileConfig() error {
profilePath, err := codexProfileConfigPath()
if err != nil {
return err
}
return removeCodexFile(profilePath)
}
func removeCodexModelCatalogIfUnused(configPath string) error {
catalogPath := codexModelCatalogPathForConfig(configPath)
data, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil {
config, parseErr := codexParseConfig(string(data))
if parseErr != nil {
return parseErr
}
if config.RootString(codexRootModelCatalogJSONKey) == catalogPath {
return nil
}
}
return removeCodexFile(catalogPath)
}
func removeCodexFile(path string) error {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func codexValidateExtraArgs(args []string) error {
for i, arg := range args {
switch {
case arg == "-p", strings.HasPrefix(arg, "-p"):
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --profile", arg)
case arg == "--profile", strings.HasPrefix(arg, "--profile="):
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --profile", arg)
case arg == "-m", strings.HasPrefix(arg, "-m"):
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --model", arg)
case arg == "--model", strings.HasPrefix(arg, "--model="):
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --model", arg)
case arg == "-c", arg == "--config":
if i+1 < len(args) && codexConfigOverrideConflicts(args[i+1]) {
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", args[i+1])
}
case strings.HasPrefix(arg, "-c") && len(arg) > len("-c"):
if codexConfigOverrideConflicts(strings.TrimPrefix(arg, "-c")) {
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", arg)
}
case strings.HasPrefix(arg, "--config="):
if codexConfigOverrideConflicts(strings.TrimPrefix(arg, "--config=")) {
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", arg)
}
}
}
return nil
}
func codexManagedConfigOverrides(modelCatalogPath string) []string {
overrides := []string{
fmt.Sprintf("%s=%q", codexRootModelProviderKey, codexProfileName),
fmt.Sprintf("model_providers.%s.name=%q", codexProfileName, codexProviderName),
fmt.Sprintf("model_providers.%s.base_url=%q", codexProfileName, codexBaseURL()),
fmt.Sprintf("model_providers.%s.wire_api=%q", codexProfileName, "responses"),
}
if modelCatalogPath != "" {
overrides = append(overrides, fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
}
return overrides
}
func codexConfigOverrideConflicts(value string) bool {
key, _, ok := strings.Cut(strings.TrimSpace(value), "=")
if !ok {
return false
}
key = strings.TrimSpace(key)
key = strings.Trim(key, `"'`)
switch {
case key == codexRootProfileKey,
key == codexRootModelKey,
key == codexRootModelProviderKey,
key == codexRootModelCatalogJSONKey:
return true
case strings.HasPrefix(key, "model_providers."):
return true
}
return false
}
// ensureCodexConfig writes a Codex profile file and model catalog so Codex uses
// the local Ollama server without changing app-visible root config.
func ensureCodexConfig(modelName string, models []LaunchModel) error {
configPath, err := codexConfigPath()
if err != nil {
@@ -79,13 +215,17 @@ func ensureCodexConfig(modelName string, models []LaunchModel) error {
if err := os.MkdirAll(codexDir, 0o755); err != nil {
return err
}
if err := cleanupCodexLegacyProfileConfig(configPath); err != nil {
return err
}
catalogPath := codexModelCatalogPathForConfig(configPath)
if err := writeCodexModelCatalog(catalogPath, codexCatalogModel(modelName, models)); err != nil {
return err
}
return writeCodexProfile(configPath, catalogPath)
profilePath := codexProfileConfigPathForConfig(configPath)
return writeCodexProfileConfig(profilePath, modelName, catalogPath)
}
func codexConfigPath() (string, error) {
@@ -108,123 +248,90 @@ 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, modelCatalogPath ...string) error {
opts := codexLaunchProfileOptions{
forceAPIAuth: true,
func codexProfileConfigPath() (string, error) {
configPath, err := codexConfigPath()
if err != nil {
return "", err
}
if len(modelCatalogPath) > 0 {
opts.modelCatalogPath = modelCatalogPath[0]
}
return writeCodexLaunchProfile(configPath, opts)
return codexProfileConfigPathForConfig(configPath), nil
}
type codexLaunchProfileOptions struct {
activate bool
profileName string
forceAPIAuth bool
setRootModelConfig bool
model string
modelCatalogPath string
backupIntegration string
func codexProfileConfigPathForConfig(configPath string) string {
return codexNamedProfileConfigPathForConfig(configPath, codexProfileName)
}
func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) error {
baseURL := codexBaseURL()
profileName := codexLaunchProfileName(opts)
profileHeader := codexProfileHeaderFor(profileName)
providerHeader := codexProviderHeaderFor(profileName)
func codexNamedProfileConfigPathForConfig(configPath, profileName string) string {
return filepath.Join(filepath.Dir(configPath), profileName+".config.toml")
}
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
} else if !os.IsNotExist(readErr) {
return readErr
func cleanupCodexLegacyProfileConfig(configPath string) error {
content, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
text := string(content)
parsed, err := codexParseConfig(text)
if err != nil {
return err
}
model := strings.TrimSpace(opts.model)
if model == "" {
model = parsed.ProfileString(profileName, codexRootModelKey)
updated := text
if profile, ok := parsed.RootStringOK(codexRootProfileKey); ok && profile == codexProfileName {
updated = codexRemoveRootValue(updated, codexRootProfileKey)
}
modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath)
if modelCatalogPath == "" {
modelCatalogPath = parsed.ProfileString(profileName, codexRootModelCatalogJSONKey)
if parsed.Exists("profiles", codexProfileName) {
updated = codexRemoveSection(updated, codexProfileHeader())
}
if updated == text {
return nil
}
if err := codexValidateConfigText(updated); err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, []byte(updated), "")
}
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))
}
// writeCodexProfileConfig ensures ~/.codex/ollama-launch.config.toml selects
// the Ollama provider and catalog for CLI launches without changing root config.
func writeCodexProfileConfig(profilePath, model, modelCatalogPath string) error {
return writeCodexNamedProfileConfig(profilePath, codexProfileName, model, modelCatalogPath, "")
}
sections := []struct {
header string
lines []string
}{
{
header: profileHeader,
lines: profileLines,
},
{
header: providerHeader,
lines: []string{
fmt.Sprintf("name = %q", codexProviderName),
fmt.Sprintf("base_url = %q", baseURL),
`wire_api = "responses"`,
},
},
}
func writeCodexNamedProfileConfig(profilePath, profileName, model, modelCatalogPath, backupSubdir string) error {
baseURL := codexBaseURL()
if opts.activate {
text = codexSetRootStringValue(text, codexRootProfileKey, profileName)
var lines []string
if strings.TrimSpace(model) != "" {
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelKey, model))
}
if opts.setRootModelConfig {
if model != "" {
text = codexSetRootStringValue(text, codexRootModelKey, model)
}
text = codexSetRootStringValue(text, codexRootModelProviderKey, profileName)
if modelCatalogPath != "" {
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
}
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName))
if strings.TrimSpace(modelCatalogPath) != "" {
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath))
}
text := strings.Join(lines, "\n") + "\n\n"
text += strings.Join([]string{
codexProviderHeaderFor(profileName),
fmt.Sprintf("name = %q", codexProviderName),
fmt.Sprintf("base_url = %q", baseURL),
`wire_api = "responses"`,
"",
}, "\n")
for _, s := range sections {
text = codexUpsertSection(text, s.header, s.lines)
}
parsed, err = codexParseConfig(text)
parsed, err := codexParseConfig(text)
if err != nil {
return err
}
if err := codexValidateLaunchProfileText(parsed, profileName, opts, model, modelCatalogPath, baseURL); err != nil {
if err := codexValidateProfileConfigText(parsed, profileName, model, modelCatalogPath, baseURL); err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
if err := os.MkdirAll(filepath.Dir(profilePath), 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
return fileutil.WriteWithBackup(profilePath, []byte(text), backupSubdir)
}
func codexBaseURL() string {
@@ -247,13 +354,14 @@ 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 {
func codexValidateProfileConfigText(config codexParsedConfig, profileName, model, modelCatalogPath, baseURL string) error {
if config.Exists("profiles", profileName) {
return fmt.Errorf("generated Codex config still contains legacy profiles.%s table", profileName)
}
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"},
@@ -262,39 +370,20 @@ func codexValidateLaunchProfileText(config codexParsedConfig, profileName string
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 got, ok := config.RootStringOK(codexRootProfileKey); ok {
return fmt.Errorf("generated Codex config still contains legacy profile = %q", got)
}
if got := config.RootString(codexRootModelProviderKey); got != profileName {
return fmt.Errorf("generated Codex config missing model_provider = %q", profileName)
}
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 got := config.RootString(codexRootModelKey); got != model {
return fmt.Errorf("generated Codex config missing model = %q", 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)
}
if got := config.RootString(codexRootModelCatalogJSONKey); got != modelCatalogPath {
return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath)
}
}
return nil
@@ -356,6 +445,24 @@ func (c codexParsedConfig) String(path ...string) (string, bool) {
return value, true
}
func (c codexParsedConfig) Exists(path ...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
}
}
return true
}
func (c codexParsedConfig) RootString(key string) string {
value, _ := c.RootStringOK(key)
return value
@@ -574,6 +681,7 @@ func codexRootLineHasKey(line, key string) bool {
func codexCatalogModel(modelName string, models []LaunchModel) LaunchModel {
if model, ok := findLaunchModel(models, modelName); ok {
model.Name = modelName
return model.WithCloudLimits()
}
return fallbackLaunchModel(modelName)
@@ -661,10 +769,10 @@ func checkCodexVersion() error {
}
version := "v" + fields[len(fields)-1]
minVersion := "v0.81.0"
minVersion := "v0.134.0"
if semver.Compare(version, minVersion) < 0 {
return fmt.Errorf("codex version %s is too old, minimum required is %s, update with: npm update -g @openai/codex", fields[len(fields)-1], "0.81.0")
return fmt.Errorf("codex version %s is too old, minimum required is %s, update with: npm update -g @openai/codex", fields[len(fields)-1], "0.134.0")
}
return nil
+140 -20
View File
@@ -85,14 +85,7 @@ func (c *CodexApp) ConfigureWithModels(primary string, models []LaunchModel) err
if err := writeCodexAppModelCatalog(catalogPath, primary, codexAppCatalogModels(primary, models)); err != nil {
return err
}
return writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
activate: true,
profileName: codexAppProfileName,
setRootModelConfig: true,
model: primary,
modelCatalogPath: catalogPath,
backupIntegration: codexAppIntegrationName,
})
return writeCodexAppConfig(configPath, primary, catalogPath)
}
func (c *CodexApp) CurrentModel() string {
@@ -160,7 +153,7 @@ func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool {
if config.RootString(codexRootModelCatalogJSONKey) != catalogPath {
return false
}
if config.ProfileString(profileName, codexRootModelCatalogJSONKey) != catalogPath {
if config.Exists("profiles", profileName) && config.ProfileString(profileName, codexRootModelCatalogJSONKey) != catalogPath {
return false
}
data, err := os.ReadFile(catalogPath)
@@ -176,6 +169,69 @@ func codexAppCatalogHealthy(config codexParsedConfig, profileName string) bool {
return len(catalog.Models) > 0
}
func writeCodexAppConfig(configPath, model, modelCatalogPath string) error {
baseURL := codexBaseURL()
content, readErr := os.ReadFile(configPath)
text := ""
if readErr == nil {
text = string(content)
} else if !os.IsNotExist(readErr) {
return readErr
}
if _, err := codexParseConfig(text); err != nil {
return err
}
text = codexRemoveRootValue(text, codexRootProfileKey)
text = codexRemoveSection(text, codexProfileHeaderFor(codexAppProfileName))
text = codexSetRootStringValue(text, codexRootModelKey, model)
text = codexSetRootStringValue(text, codexRootModelProviderKey, codexAppProfileName)
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
text = codexUpsertSection(text, codexProviderHeaderFor(codexAppProfileName), []string{
fmt.Sprintf("name = %q", codexProviderName),
fmt.Sprintf("base_url = %q", baseURL),
`wire_api = "responses"`,
})
parsed, err := codexParseConfig(text)
if err != nil {
return err
}
if err := codexValidateAppConfigText(parsed, 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), codexAppIntegrationName)
}
func codexValidateAppConfigText(config codexParsedConfig, model, modelCatalogPath, baseURL string) error {
if got, ok := config.RootStringOK(codexRootProfileKey); ok {
return fmt.Errorf("generated Codex App config still contains legacy profile = %q", got)
}
if config.Exists("profiles", codexAppProfileName) {
return fmt.Errorf("generated Codex App config still contains legacy profiles.%s table", codexAppProfileName)
}
for _, check := range []struct {
path []string
want string
}{
{[]string{codexRootModelKey}, model},
{[]string{codexRootModelProviderKey}, codexAppProfileName},
{[]string{codexRootModelCatalogJSONKey}, modelCatalogPath},
{[]string{"model_providers", codexAppProfileName, "name"}, codexProviderName},
{[]string{"model_providers", codexAppProfileName, "base_url"}, baseURL},
{[]string{"model_providers", codexAppProfileName, "wire_api"}, "responses"},
} {
if got, ok := config.String(check.path...); !ok || got != check.want {
return fmt.Errorf("generated Codex App config missing %s = %q", strings.Join(check.path, "."), check.want)
}
}
return nil
}
func (c *CodexApp) Onboard() error {
return config.MarkIntegrationOnboarded(codexAppIntegrationName)
}
@@ -203,7 +259,7 @@ func (c *CodexApp) Run(_ string, _ []LaunchModel, args []string) error {
if len(args) > 0 {
return fmt.Errorf("codex-app does not accept extra arguments")
}
return codexAppLaunchOrRestart("Restart Codex to use Ollama?")
return codexAppLaunchOrRestart("Restart Codex to use Ollama?", nil)
}
func (c *CodexApp) Restore() error {
@@ -221,7 +277,13 @@ func (c *CodexApp) Restore() error {
if err := removeCodexAppRestoreState(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?")
if err := removeCodexAppProfileConfig(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := codexAppRemoveOwnedCatalog(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?", nil)
}
return codexAppRestoreFailure(configPath, err)
}
@@ -248,13 +310,16 @@ func (c *CodexApp) Restore() error {
if err := fileutil.WriteWithBackup(configPath, []byte(text), codexAppIntegrationName); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := removeCodexAppProfileConfig(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := codexAppRemoveOwnedCatalogIfUnused(text); err != nil {
return codexAppRestoreFailure(configPath, err)
}
if err := removeCodexAppRestoreState(); err != nil {
return codexAppRestoreFailure(configPath, err)
}
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?")
return codexAppLaunchOrRestart("Restart Codex to use your usual profile?", nil)
}
func codexAppRestoreFailure(configPath string, err error) error {
@@ -298,6 +363,18 @@ func codexAppModelCatalogPath() (string, error) {
return codexAppModelCatalogPathForConfig(configPath), nil
}
func codexAppProfileConfigPath() (string, error) {
configPath, err := codexConfigPath()
if err != nil {
return "", err
}
return codexAppProfileConfigPathForConfig(configPath), nil
}
func codexAppProfileConfigPathForConfig(configPath string) string {
return codexNamedProfileConfigPathForConfig(configPath, codexAppProfileName)
}
func codexAppModelCatalogPathForConfig(configPath string) string {
return filepath.Join(filepath.Dir(configPath), codexAppModelCatalogFilename)
}
@@ -327,14 +404,20 @@ func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel {
seen := make(map[string]bool, len(models)+1)
out := make([]LaunchModel, 0, len(models)+1)
add := func(model LaunchModel) {
if model.Name == "" || seen[model.Name] {
model.Name = strings.TrimSpace(model.Name)
if model.Name == "" {
return
}
seen[model.Name] = true
key := codexAppCatalogModelKey(model.Name)
if seen[key] {
return
}
seen[key] = true
out = append(out, model)
}
if model, ok := findLaunchModel(models, primary); ok {
model.Name = primary
add(model)
} else {
add(fallbackLaunchModel(primary))
@@ -345,6 +428,10 @@ func codexAppCatalogModels(primary string, models []LaunchModel) []LaunchModel {
return out
}
func codexAppCatalogModelKey(name string) string {
return strings.TrimSuffix(name, ":latest")
}
type codexAppModelMetadata struct {
contextWindow int
inputModalities []string
@@ -523,13 +610,13 @@ func codexAppLocalAppData() (string, error) {
return filepath.Join(home, "AppData", "Local"), nil
}
func codexAppLaunchOrRestart(prompt string) error {
func codexAppLaunchOrRestart(prompt string, launchArgs []string) error {
if !codexAppIsRunning() {
return codexAppOpenApp()
return codexAppOpenApp(launchArgs)
}
restartAppID := ""
restartAppPath := ""
if codexAppGOOS == "windows" {
if len(launchArgs) == 0 && codexAppGOOS == "windows" {
restartAppID = codexAppStartID()
if restartAppID == "" {
restartAppPath = codexAppRunPath()
@@ -570,7 +657,7 @@ func codexAppLaunchOrRestart(prompt string) error {
if restartAppPath != "" {
return codexAppOpenPath(restartAppPath)
}
return codexAppOpenApp()
return codexAppOpenApp(launchArgs)
}
func codexAppForceQuitSupported() bool {
@@ -603,7 +690,15 @@ func waitForCodexAppCondition(timeout time.Duration, done func() bool) error {
return fmt.Errorf("Codex did not quit; quit it manually and re-run the command")
}
func defaultCodexAppOpenApp() error {
func defaultCodexAppOpenApp(args []string) error {
if len(args) > 0 {
cmd := exec.Command("codex", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(), "OPENAI_API_KEY=ollama")
return cmd.Run()
}
switch codexAppGOOS {
case "windows":
if path := codexAppAppPath(); path != "" {
@@ -866,6 +961,10 @@ func codexAppRemoveOwnedCatalogIfUnused(text string) error {
if codexAppRootReferencesCatalog(text) {
return nil
}
return codexAppRemoveOwnedCatalog()
}
func codexAppRemoveOwnedCatalog() error {
if catalogPath, err := codexAppModelCatalogPath(); err == nil {
if err := os.Remove(catalogPath); err != nil && !os.IsNotExist(err) {
return err
@@ -876,6 +975,17 @@ func codexAppRemoveOwnedCatalogIfUnused(text string) error {
return nil
}
func removeCodexAppProfileConfig() error {
profilePath, err := codexAppProfileConfigPath()
if err != nil {
return err
}
if err := os.Remove(profilePath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func codexAppRemoveOwnedRootValues(text string) string {
config, err := codexParseConfig(text)
if err != nil {
@@ -953,6 +1063,12 @@ func saveCodexAppRestoreState(configPath string) error {
return err
}
upgraded := codexAppRestoreStateFromText(configText)
if codexAppRootStillManaged(configText) {
// Legacy restore state did not record root model settings. If the
// current config is still ours, do not save our generated root
// values as the user's restore target.
upgraded = codexAppRestoreState{}
}
upgraded.HadProfile = existing.HadProfile
upgraded.Profile = existing.Profile
return writeCodexAppRestoreState(upgraded)
@@ -960,7 +1076,11 @@ func saveCodexAppRestoreState(configPath string) error {
return err
}
return writeCodexAppRestoreState(codexAppRestoreStateFromText(configText))
state := codexAppRestoreStateFromText(configText)
if codexAppRootStillManaged(configText) {
state = codexAppRestoreState{}
}
return writeCodexAppRestoreState(state)
}
func codexAppRestoreStateHasRootConfig(data []byte) (bool, error) {
+260 -20
View File
@@ -39,7 +39,7 @@ func withCodexAppProcessHooks(t *testing.T, isRunning func() bool, quit func() e
codexAppIsRunning = isRunning
codexAppHasWindow = isRunning
codexAppQuitApp = quit
codexAppOpenApp = open
codexAppOpenApp = func([]string) error { return open() }
t.Cleanup(func() {
codexAppIsRunning = oldIsRunning
codexAppQuitApp = oldQuit
@@ -157,7 +157,7 @@ func TestCodexAppInstalledUsesMacBundleIDFallback(t *testing.T) {
}
}
func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) {
func TestCodexAppConfigureActivatesOllamaProviderWithoutLegacyProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
@@ -180,26 +180,20 @@ func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
fmt.Sprintf(`profile = %q`, codexAppProfileName),
`model = "llama3.2"`,
fmt.Sprintf(`model_provider = %q`, codexAppProfileName),
fmt.Sprintf(`model_catalog_json = %q`, catalogPath),
codexProfileHeaderFor(codexAppProfileName),
`model = "llama3.2"`,
`openai_base_url = "http://127.0.0.1:9999/v1/"`,
fmt.Sprintf(`model_provider = %q`, codexAppProfileName),
`model_catalog_json = "`,
codexProviderHeaderFor(codexAppProfileName),
`name = "Ollama"`,
`base_url = "http://127.0.0.1:9999/v1/"`,
@@ -210,6 +204,12 @@ func TestCodexAppConfigureActivatesOllamaProfile(t *testing.T) {
t.Fatalf("expected config to contain %q, got:\n%s", want, content)
}
}
if got, ok := codexRootStringValueOK(content, "profile"); ok {
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
}
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) {
t.Fatalf("legacy app profile section should not be generated, got:\n%s", content)
}
if got := c.CurrentModel(); got != "llama3.2" {
t.Fatalf("CurrentModel = %q, want llama3.2", got)
}
@@ -270,8 +270,8 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes
t.Fatal(err)
}
content := string(data)
if got := codexRootStringValue(content, "profile"); got != codexAppProfileName {
t.Fatalf("root profile = %q, want %q", got, codexAppProfileName)
if got, ok := codexRootStringValueOK(content, "profile"); ok {
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
}
if got := codexSectionStringValue(content, codexProfileHeader(), "openai_base_url"); got != "http://cli.invalid/v1/" {
t.Fatalf("CLI profile base URL = %q, want preserved CLI URL in:\n%s", got, content)
@@ -279,8 +279,11 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes
if got := codexSectionStringValue(content, codexProviderHeader(), "name"); got != "CLI Ollama" {
t.Fatalf("CLI provider name = %q, want preserved CLI provider in:\n%s", got, content)
}
if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "model"); got != "llama3.2" {
t.Fatalf("app profile model = %q, want llama3.2", got)
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) {
t.Fatalf("legacy app profile section should not be generated, got:\n%s", content)
}
if got := codexRootStringValue(content, "model"); got != "llama3.2" {
t.Fatalf("root model = %q, want llama3.2", got)
}
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" {
t.Fatalf("app provider base URL = %q", got)
@@ -288,6 +291,98 @@ func TestCodexAppConfigureUsesAppSpecificProfileWithoutTouchingCLIProfile(t *tes
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), codexAppIntegrationName, "config.toml.*"), `profile = "default"`)
}
func TestCodexCLIConfigRefreshLeavesCodexAppConfigActive(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
appModels := testLaunchModels("llama3.2", "gemma4")
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", appModels); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
appCatalogPath := mustCodexAppModelCatalogPath(t)
if err := ensureCodexConfig("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil {
t.Fatalf("ensureCodexConfig returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if got, ok := codexRootStringValueOK(content, "profile"); ok {
t.Fatalf("CLI config refresh should not activate a root profile, got %q in:\n%s", got, content)
}
for key, want := range map[string]string{
"model": "llama3.2",
"model_provider": codexAppProfileName,
"model_catalog_json": appCatalogPath,
} {
if got := codexRootStringValue(content, key); got != want {
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
}
}
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:9999/v1/" {
t.Fatalf("app provider base URL = %q", got)
}
cliCatalogPath := filepath.Join(tmpDir, ".codex", "model.json")
if strings.Contains(content, codexProfileHeader()) {
t.Fatalf("CLI legacy profile section should not be generated, got:\n%s", content)
}
if strings.Contains(content, codexProviderHeader()) {
t.Fatalf("CLI provider should be isolated from app root config, got:\n%s", content)
}
cliProfilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
cliProfileData, err := os.ReadFile(cliProfilePath)
if err != nil {
t.Fatalf("CLI profile config not created: %v", err)
}
cliProfile := string(cliProfileData)
for key, want := range map[string]string{
"model": "qwen3:8b",
"model_provider": codexProfileName,
"model_catalog_json": cliCatalogPath,
} {
if got := codexRootStringValue(cliProfile, key); got != want {
t.Fatalf("CLI profile %s = %q, want %q in:\n%s", key, got, want, cliProfile)
}
}
if got := codexSectionStringValue(cliProfile, codexProviderHeader(), "base_url"); got != "http://127.0.0.1:9999/v1/" {
t.Fatalf("CLI profile provider base URL = %q", got)
}
appCatalogData, err := os.ReadFile(appCatalogPath)
if err != nil {
t.Fatal(err)
}
var appCatalog struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(appCatalogData, &appCatalog); err != nil {
t.Fatalf("app catalog should be valid JSON: %v", err)
}
if got := catalogSlugs(appCatalog.Models); strings.Join(got, ",") != "llama3.2,gemma4" {
t.Fatalf("app catalog slugs = %v, want original app models", got)
}
cliCatalogData, err := os.ReadFile(cliCatalogPath)
if err != nil {
t.Fatal(err)
}
var cliCatalog struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(cliCatalogData, &cliCatalog); err != nil {
t.Fatalf("CLI catalog should be valid JSON: %v", err)
}
if got := catalogSlugs(cliCatalog.Models); strings.Join(got, ",") != "qwen3:8b" {
t.Fatalf("CLI catalog slugs = %v, want qwen3:8b", got)
}
}
func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -306,8 +401,8 @@ func TestCodexAppConfigureUsesConnectableHostForUnspecifiedBindAddress(t *testin
if strings.Contains(content, "0.0.0.0") {
t.Fatalf("config should not write bind-only host, got:\n%s", content)
}
if got := codexSectionStringValue(content, codexProfileHeaderFor(codexAppProfileName), "openai_base_url"); got != "http://127.0.0.1:11434/v1/" {
t.Fatalf("app profile openai_base_url = %q, want connectable loopback URL", got)
if strings.Contains(content, codexProfileHeaderFor(codexAppProfileName)) {
t.Fatalf("legacy app profile section should not be generated, got:\n%s", content)
}
if got := codexSectionStringValue(content, codexProviderHeaderFor(codexAppProfileName), "base_url"); got != "http://127.0.0.1:11434/v1/" {
t.Fatalf("app provider base_url = %q, want connectable loopback URL", got)
@@ -592,6 +687,52 @@ func TestCodexAppConfigurePopulatesCatalogFromEnrichedModels(t *testing.T) {
}
}
func TestCodexAppConfigureCatalogIncludesExactSelectedModel(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
models := []LaunchModel{
{Name: "llama3.2:latest", ContextLength: 65_536},
{Name: "qwen3:8b"},
}
if err := (&CodexApp{}).ConfigureWithModels("llama3.2", models); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
configPath, err := codexConfigPath()
if err != nil {
t.Fatal(err)
}
configData, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if got := codexRootStringValue(string(configData), codexRootModelKey); got != "llama3.2" {
t.Fatalf("root model = %q, want llama3.2", got)
}
catalogPath, err := codexAppModelCatalogPath()
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(catalogPath)
if err != nil {
t.Fatal(err)
}
var catalog struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(data, &catalog); err != nil {
t.Fatalf("catalog should be valid JSON: %v", err)
}
if got := catalogSlugs(catalog.Models); strings.Join(got, ",") != "llama3.2,qwen3:8b" {
t.Fatalf("catalog slugs = %v, want exact selected model without :latest duplicate", got)
}
if got := catalog.Models[0]["context_window"]; got != float64(65_536) {
t.Fatalf("selected model context_window = %v, want 65536", got)
}
}
func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -635,6 +776,105 @@ func TestCodexAppConfigureUpgradesLegacyRestoreState(t *testing.T) {
}
}
func TestCodexAppConfigureMigratesLegacyManagedConfigWithoutPollutingRestoreState(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:9999")
withCodexAppPlatform(t, "darwin")
var openCalls int
withCodexAppProcessHooks(t,
func() bool { return false },
func() error { return nil },
func() error {
openCalls++
return nil
},
)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
catalogPath := mustCodexAppModelCatalogPath(t)
existing := "" +
fmt.Sprintf(`profile = %q`, codexAppProfileName) + "\n" +
`model = "llama3.2"` + "\n" +
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" +
codexProfileHeaderFor(codexAppProfileName) + "\n" +
`model = "llama3.2"` + "\n" +
fmt.Sprintf(`model_provider = %q`, codexAppProfileName) + "\n" +
fmt.Sprintf(`model_catalog_json = %q`, catalogPath) + "\n\n" +
codexProviderHeaderFor(codexAppProfileName) + "\n" +
`name = "Ollama"` + "\n" +
`base_url = "http://127.0.0.1:9999/v1/"` + "\n" +
`wire_api = "responses"` + "\n\n" +
"[profiles.default]\n" +
`model = "gpt-5.5"` + "\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(`{"had_profile":true,"profile":"default"}`), 0o644); err != nil {
t.Fatal(err)
}
c := &CodexApp{}
if err := c.ConfigureWithModels("qwen3:8b", testLaunchModels("qwen3:8b")); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
state, err := loadCodexAppRestoreState()
if err != nil {
t.Fatal(err)
}
if !state.HadProfile || state.Profile != "default" {
t.Fatalf("profile restore state = (%v, %q), want default", state.HadProfile, state.Profile)
}
if state.HadModel || state.HadModelProvider || state.HadModelCatalogJSON {
t.Fatalf("legacy restore state should not capture managed root values: %+v", state)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
migrated := string(data)
if got, ok := codexRootStringValueOK(migrated, "profile"); ok {
t.Fatalf("legacy root profile should be removed during migration, got %q in:\n%s", got, migrated)
}
if strings.Contains(migrated, codexProfileHeaderFor(codexAppProfileName)) {
t.Fatalf("legacy app profile section should be removed during migration, got:\n%s", migrated)
}
if err := c.Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err = os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
restored := string(data)
if got := codexRootStringValue(restored, "profile"); got != "default" {
t.Fatalf("root profile = %q, want default in:\n%s", got, restored)
}
for _, key := range []string{"model", "model_provider", "model_catalog_json"} {
if got, ok := codexRootStringValueOK(restored, key); ok {
t.Fatalf("root %s should be removed on restore, got %q in:\n%s", key, got, restored)
}
}
if strings.Contains(restored, codexProfileHeaderFor(codexAppProfileName)) || strings.Contains(restored, codexProviderHeaderFor(codexAppProfileName)) {
t.Fatalf("owned app config should be removed on restore, got:\n%s", restored)
}
if openCalls != 1 {
t.Fatalf("open calls = %d, want 1", openCalls)
}
}
func TestCodexAppRestoreRestoresPreviousProfile(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -1237,7 +1477,7 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) {
defer restoreConfirm()
running := true
var quitCalls int
var quitCalls, openCalls int
withCodexAppProcessHooks(t,
func() bool { return running },
func() error {
@@ -1246,7 +1486,7 @@ func TestCodexAppRunRestartsWindowsStartAppID(t *testing.T) {
return nil
},
func() error {
t.Fatal("open app fallback should not be used")
openCalls++
return nil
},
)
+405 -277
View File
@@ -1,6 +1,7 @@
package launch
import (
"context"
"encoding/json"
"fmt"
"os"
@@ -14,10 +15,30 @@ import (
modelpkg "github.com/ollama/ollama/types/model"
)
func TestCodexIntegration(t *testing.T) {
c := &Codex{}
t.Run("implements runner", func(t *testing.T) {
var _ Runner = c
})
t.Run("implements restore", func(t *testing.T) {
var _ RestorableIntegration = c
var _ RestoreSuccessIntegration = c
var _ RestoreInstallCheckSkipper = c
})
}
func TestCodexArgs(t *testing.T) {
c := &Codex{}
catalogPath := filepath.Join("tmp", "model.json")
catalogArg := fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath)
managedArgs := []string{
"--profile", "ollama-launch",
"-c", fmt.Sprintf("%s=%q", codexRootModelProviderKey, codexProfileName),
"-c", fmt.Sprintf("model_providers.%s.name=%q", codexProfileName, codexProviderName),
"-c", fmt.Sprintf("model_providers.%s.base_url=%q", codexProfileName, codexBaseURL()),
"-c", fmt.Sprintf("model_providers.%s.wire_api=%q", codexProfileName, "responses"),
"-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath),
}
tests := []struct {
name string
@@ -25,15 +46,17 @@ func TestCodexArgs(t *testing.T) {
args []string
want []string
}{
{"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"}},
{"with model", "llama3.2", nil, append(slices.Clone(managedArgs), "-m", "llama3.2")},
{"empty model", "", nil, managedArgs},
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, append(append(slices.Clone(managedArgs), "-m", "llama3.2"), "--sandbox", "workspace-write")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.args(tt.model, catalogPath, tt.args)
got, err := c.args(tt.model, catalogPath, tt.args)
if err != nil {
t.Fatal(err)
}
if !slices.Equal(got, tt.want) {
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
}
@@ -41,301 +64,117 @@ func TestCodexArgs(t *testing.T) {
}
}
func TestWriteCodexProfile(t *testing.T) {
func TestCodexArgsRejectManagedProfile(t *testing.T) {
c := &Codex{}
for _, extra := range [][]string{
{"-p", "myprofile"},
{"-pmyprofile"},
{"--profile", "myprofile"},
{"--profile=myprofile"},
} {
t.Run(strings.Join(extra, " "), func(t *testing.T) {
_, err := c.args("llama3.2", "", extra)
if err == nil || !strings.Contains(err.Error(), "manages --profile") {
t.Fatalf("args error = %v, want profile conflict", err)
}
})
}
}
func TestCodexArgsRejectManagedOverrides(t *testing.T) {
c := &Codex{}
for _, extra := range [][]string{
{"-m", "other"},
{"-mother"},
{"--model", "other"},
{"--model=other"},
{"-c", `model_catalog_json="/tmp/other.json"`},
{"--config", `model_provider="openai"`},
{"--config=model_providers.ollama-launch.base_url=\"http://other.invalid/v1/\""},
} {
t.Run(strings.Join(extra, " "), func(t *testing.T) {
_, err := c.args("llama3.2", "", extra)
if err == nil {
t.Fatalf("args error = nil, want managed config conflict")
}
})
}
}
func TestWriteCodexProfileConfig(t *testing.T) {
t.Run("creates new file when none exists", func(t *testing.T) {
tmpDir := t.TempDir()
configPath := filepath.Join(tmpDir, "config.toml")
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
catalogPath := filepath.Join(tmpDir, "model.json")
if err := writeCodexProfile(configPath, catalogPath); err != nil {
if err := writeCodexProfileConfig(profilePath, "llama3.2", catalogPath); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(configPath)
data, err := os.ReadFile(profilePath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if !strings.Contains(content, "[profiles.ollama-launch]") {
t.Error("missing [profiles.ollama-launch] header")
}
if !strings.Contains(content, "openai_base_url") {
t.Error("missing openai_base_url key")
}
if !strings.Contains(content, "/v1/") {
t.Error("missing /v1/ suffix in base URL")
}
if !strings.Contains(content, `forced_login_method = "api"`) {
t.Error("missing forced_login_method key")
}
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, catalogPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if !strings.Contains(content, "[some_other_section]") {
t.Error("existing section was removed")
}
if !strings.Contains(content, "[profiles.ollama-launch]") {
t.Error("missing [profiles.ollama-launch] header")
}
})
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, catalogPath); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, "old:1234") {
t.Error("old URL was not replaced")
}
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
t.Errorf("expected exactly one [profiles.ollama-launch] section, got %d", strings.Count(content, "[profiles.ollama-launch]"))
}
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,
for _, want := range []string{
`model = "llama3.2"`,
`model_provider = "ollama-launch"`,
fmt.Sprintf("model_catalog_json = %q", catalogPath),
"[model_providers.ollama-launch]",
`name = "Ollama"`,
`base_url = "http://127.0.0.1:11434/v1/"`,
`wire_api = "responses"`,
} {
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, want) {
t.Errorf("missing %q in:\n%s", 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 got, ok := codexRootStringValueOK(content, "profile"); ok {
t.Fatalf("legacy root profile should not be generated, got %q in:\n%s", got, content)
}
if strings.Contains(content, "[profiles.ollama-launch]") {
t.Fatalf("legacy profile section should not be generated, 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) {
t.Run("overwrites owned profile and backs up previous profile", 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, catalogPath); err != nil {
setTestHome(t, tmpDir)
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
t.Fatal(err)
}
existing := "# original-codex-profile-backup-marker\nmodel = \"old\"\nmodel_provider = \"old-provider\"\n"
if err := os.WriteFile(profilePath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
content := string(data)
if strings.Contains(content, "old:1234") {
t.Error("old URL was not replaced")
}
if !strings.Contains(content, "[another_section]") {
t.Error("following section was removed")
}
if !strings.Contains(content, "foo = \"bar\"") {
t.Error("following section content was removed")
}
})
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, catalogPath); err != nil {
if err := writeCodexProfileConfig(profilePath, "llama3.2", ""); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
data, _ := os.ReadFile(profilePath)
content := string(data)
if !strings.Contains(content, "[profiles.ollama-launch]") {
t.Error("missing [profiles.ollama-launch] header")
}
// Should not have double blank lines from missing trailing newline
if strings.Contains(content, "\n\n\n") {
t.Error("unexpected triple newline in output")
if strings.Contains(content, "old-provider") {
t.Fatalf("profile should be replaced, got:\n%s", content)
}
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "ollama-launch.config.toml.*"), "original-codex-profile-backup-marker")
})
t.Run("uses custom OLLAMA_HOST", func(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")
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
if err := writeCodexProfile(configPath, catalogPath); err != nil {
if err := writeCodexProfileConfig(profilePath, "llama3.2", ""); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
data, _ := os.ReadFile(profilePath)
content := string(data)
if !strings.Contains(content, "myhost:9999/v1/") {
@@ -346,13 +185,13 @@ func TestWriteCodexProfile(t *testing.T) {
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")
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
if err := writeCodexProfile(configPath); err != nil {
if err := writeCodexProfileConfig(profilePath, "", ""); err != nil {
t.Fatal(err)
}
data, _ := os.ReadFile(configPath)
data, _ := os.ReadFile(profilePath)
content := string(data)
if strings.Contains(content, "0.0.0.0") {
@@ -365,7 +204,7 @@ func TestWriteCodexProfile(t *testing.T) {
}
func TestEnsureCodexConfig(t *testing.T) {
t.Run("creates .codex dir and config.toml", func(t *testing.T) {
t.Run("creates .codex dir, profile config, and model catalog", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -374,20 +213,33 @@ func TestEnsureCodexConfig(t *testing.T) {
}
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
data, err := os.ReadFile(configPath)
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
t.Fatalf("root config.toml should not be created by CLI config refresh, err=%v", err)
}
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
data, err := os.ReadFile(profilePath)
if err != nil {
t.Fatalf("config.toml not created: %v", err)
t.Fatalf("profile config not created: %v", err)
}
content := string(data)
if !strings.Contains(content, "[profiles.ollama-launch]") {
t.Error("missing [profiles.ollama-launch] header")
if strings.Contains(content, "[profiles.ollama-launch]") {
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
}
if !strings.Contains(content, "openai_base_url") {
t.Error("missing openai_base_url key")
if got := codexRootStringValue(content, "model"); got != "llama3.2" {
t.Fatalf("profile model = %q, want llama3.2 in:\n%s", got, content)
}
if got := codexRootStringValue(content, "model_provider"); got != codexProfileName {
t.Fatalf("profile model_provider = %q, want %q in:\n%s", got, codexProfileName, content)
}
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
if got := codexRootStringValue(content, "model_catalog_json"); got != catalogPath {
t.Fatalf("profile model_catalog_json = %q, want %q in:\n%s", got, catalogPath, content)
}
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
}
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
data, err = os.ReadFile(catalogPath)
if err != nil {
t.Fatalf("model.json not created: %v", err)
@@ -397,6 +249,40 @@ func TestEnsureCodexConfig(t *testing.T) {
}
})
t.Run("writes requested local alias as catalog slug", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
models := []LaunchModel{
{Name: "gemma4:latest", ContextLength: 65_536, Details: api.ModelDetails{Format: "gguf"}},
}
if err := ensureCodexConfig("gemma4", models); err != nil {
t.Fatal(err)
}
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
data, err := os.ReadFile(catalogPath)
if err != nil {
t.Fatalf("model.json not created: %v", err)
}
var catalog struct {
Models []map[string]any `json:"models"`
}
if err := json.Unmarshal(data, &catalog); err != nil {
t.Fatalf("model catalog should be valid JSON: %v", err)
}
if len(catalog.Models) != 1 {
t.Fatalf("catalog model count = %d, want 1", len(catalog.Models))
}
if got := catalog.Models[0]["slug"]; got != "gemma4" {
t.Fatalf("catalog slug = %v, want gemma4", got)
}
if got := catalog.Models[0]["context_window"]; got != float64(65_536) {
t.Fatalf("context_window = %v, want 65536", got)
}
})
t.Run("is idempotent", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -409,16 +295,258 @@ func TestEnsureCodexConfig(t *testing.T) {
}
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
data, _ := os.ReadFile(configPath)
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
t.Fatalf("root config.toml should not be created by CLI config refresh, err=%v", err)
}
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
data, err := os.ReadFile(profilePath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
t.Errorf("expected exactly one [profiles.ollama-launch] section after two calls, got %d", strings.Count(content, "[profiles.ollama-launch]"))
if strings.Contains(content, "[profiles.ollama-launch]") {
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
}
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
t.Errorf("expected exactly one [model_providers.ollama-launch] section after two calls, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
}
})
t.Run("cleans legacy root profile that conflicts with --profile", 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 := "" +
`profile = "ollama-launch"` + "\n" +
`model = "gpt-5.5"` + "\n" +
`model_provider = "openai"` + "\n\n" +
"[profiles.ollama-launch]\n" +
`model = "old-local"` + "\n" +
`model_provider = "ollama-launch"` + "\n\n" +
"[profiles.default]\n" +
`model = "gpt-5.5"` + "\n"
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
t.Fatal(err)
}
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
content := string(data)
if got, ok := codexRootStringValueOK(content, codexRootProfileKey); ok {
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
}
if strings.Contains(content, codexProfileHeader()) {
t.Fatalf("legacy profile table should be removed, got:\n%s", content)
}
for _, want := range []string{
`model = "gpt-5.5"`,
`model_provider = "openai"`,
"[profiles.default]",
} {
if !strings.Contains(content, want) {
t.Fatalf("expected %q to be preserved in:\n%s", want, content)
}
}
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
profileData, err := os.ReadFile(profilePath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(profileData), `model = "llama3.2"`) {
t.Fatalf("managed profile was not written with selected model:\n%s", profileData)
}
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), `profile = "ollama-launch"`)
})
}
func TestCodexRestoreRemovesCLIProfileAndCatalogWithoutChangingUserRootConfig(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)
}
userConfig := "" +
`model = "gpt-5.5"` + "\n" +
`model_provider = "openai"` + "\n\n" +
"[model_providers.openai]\n" +
`name = "OpenAI"` + "\n"
if err := os.WriteFile(configPath, []byte(userConfig), 0o644); err != nil {
t.Fatal(err)
}
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
t.Fatal(err)
}
if err := (&Codex{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
t.Fatalf("CLI profile should be removed, got err=%v", err)
}
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
t.Fatalf("CLI catalog should be removed, got err=%v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != userConfig {
t.Fatalf("user root config should be unchanged, got:\n%s", data)
}
}
func TestCodexRestoreDoesNotRewriteRootConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
legacyConfig := "" +
`profile = "ollama-launch"` + "\n" +
`model = "llama3.2"` + "\n" +
`model_provider = "ollama-launch"` + "\n" +
fmt.Sprintf("model_catalog_json = %q\n\n", catalogPath) +
"[model_providers.ollama-launch]\n" +
`name = "Ollama"` + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
`wire_api = "responses"` + "\n\n" +
"[profiles.ollama-launch]\n" +
`model = "llama3.2"` + "\n\n" +
"[tools]\n" +
`web_search = true` + "\n"
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(profilePath, []byte(`model_provider = "ollama-launch"`), 0o644); err != nil {
t.Fatal(err)
}
if err := (&Codex{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != legacyConfig {
t.Fatalf("root config should be left untouched, got:\n%s", data)
}
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
t.Fatalf("CLI profile should be removed, got err=%v", err)
}
if _, err := os.Stat(catalogPath); err != nil {
t.Fatalf("CLI catalog should be left while root config references it: %v", err)
}
}
func TestCodexRestoreDoesNotTouchCodexAppConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
cliCatalogPath := filepath.Join(tmpDir, ".codex", "model.json")
appCatalogPath := filepath.Join(tmpDir, ".codex", codexAppModelCatalogFilename)
cliProfilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
appProfilePath := filepath.Join(tmpDir, ".codex", codexAppProfileName+".config.toml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
appManagedConfig := "" +
`model = "llama3.2"` + "\n" +
fmt.Sprintf("model_provider = %q\n", codexAppProfileName) +
fmt.Sprintf("model_catalog_json = %q\n\n", appCatalogPath) +
codexProviderHeaderFor(codexAppProfileName) + "\n" +
`name = "Ollama"` + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
`wire_api = "responses"` + "\n\n" +
codexProviderHeader() + "\n" +
`name = "Ollama"` + "\n" +
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
`wire_api = "responses"` + "\n"
if err := os.WriteFile(configPath, []byte(appManagedConfig), 0o644); err != nil {
t.Fatal(err)
}
restoreState := fmt.Sprintf(`{"had_profile":false,"had_model":true,"model":"qwen3:8b","had_model_provider":true,"model_provider":%q,"had_model_catalog_json":true,"model_catalog_json":%q}`, codexProfileName, cliCatalogPath)
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
t.Fatal(err)
}
for _, path := range []string{cliCatalogPath, appCatalogPath, cliProfilePath, appProfilePath} {
if err := os.WriteFile(path, []byte(`{"models":[]}`), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
if err := (&Codex{}).Restore(); err != nil {
t.Fatalf("Restore returned error: %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
if string(data) != appManagedConfig {
t.Fatalf("Codex App root config should be left untouched, got:\n%s", data)
}
if _, err := os.Stat(cliProfilePath); !os.IsNotExist(err) {
t.Fatalf("CLI profile should be removed, got err=%v", err)
}
if _, err := os.Stat(cliCatalogPath); !os.IsNotExist(err) {
t.Fatalf("CLI catalog should be removed when root config does not reference it, got err=%v", err)
}
for _, path := range []string{appCatalogPath, appProfilePath, codexAppRestoreStatePath()} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("%s should be left untouched, got err=%v", path, err)
}
}
}
func TestLaunchIntegrationCodexRestoreDoesNotRequireInstalledCLI(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(profilePath, []byte(`model_provider = "ollama-launch"`), 0o644); err != nil {
t.Fatal(err)
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "codex", Restore: true}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
t.Fatalf("CLI restore should run without codex installed and remove profile, got err=%v", err)
}
}
func assertBackupContains(t *testing.T, pattern, marker string) {
+213 -20
View File
@@ -24,6 +24,8 @@ import (
const (
hermesInstallScript = "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup"
hermesWindowsInstallURL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1"
hermesWindowsInstallCmd = "& ([scriptblock]::Create((irm " + hermesWindowsInstallURL + "))) -SkipSetup"
hermesProviderName = "Ollama"
hermesProviderKey = "ollama-launch"
hermesLegacyKey = "ollama"
@@ -81,6 +83,138 @@ func (h *Hermes) Run(_ string, _ []LaunchModel, args []string) error {
return hermesAttachedCommand(bin, args...).Run()
}
type HermesDesktop struct {
Hermes
}
func (h *HermesDesktop) String() string { return "Hermes Desktop" }
func (h *HermesDesktop) Run(_ string, _ []LaunchModel, args []string) error {
bin, err := h.binary()
if err != nil {
return err
}
return hermesAttachedCommand(bin, h.launchArgs(args)...).Run()
}
func (h *HermesDesktop) Onboard() error {
return config.MarkIntegrationOnboarded("hermes-desktop")
}
func (h *HermesDesktop) launchArgs(args []string) []string {
launchArgs := []string{"desktop"}
if h.shouldSkipDesktopBuild(args) {
launchArgs = append(launchArgs, "--skip-build")
}
return append(launchArgs, args...)
}
func (h *HermesDesktop) shouldSkipDesktopBuild(args []string) bool {
if hermesDesktopHasFlag(args, "--skip-build", "--source", "--build-only", "--help", "-h") {
return false
}
return h.packagedAppExists()
}
func (h *HermesDesktop) packagedAppExists() bool {
for _, root := range hermesDesktopReleaseRoots() {
for _, candidate := range hermesDesktopPackagedExecutableCandidates(root) {
if _, err := os.Stat(candidate); err == nil {
return true
}
}
}
return false
}
// These roots mirror Hermes' own install layout:
// scripts/install.sh uses ~/.hermes/hermes-agent for user installs and
// /usr/local/lib/hermes-agent for new Linux root installs; scripts/install.ps1
// and the bootstrap installer use %LOCALAPPDATA%\hermes\hermes-agent on
// Windows. HERMES_HOME and HERMES_INSTALL_DIR are installer-supported
// overrides.
func hermesDesktopReleaseRoots() []string {
var installRoots []string
add := func(path string) {
path = strings.TrimSpace(path)
if path == "" {
return
}
installRoots = append(installRoots, filepath.Clean(path))
}
if installDir := strings.TrimSpace(os.Getenv("HERMES_INSTALL_DIR")); installDir != "" {
add(installDir)
}
if hermesHome := strings.TrimSpace(os.Getenv("HERMES_HOME")); hermesHome != "" {
add(filepath.Join(hermesHome, "hermes-agent"))
}
home, err := hermesUserHome()
if err == nil {
switch hermesGOOS {
case "windows":
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
add(filepath.Join(localAppData, "hermes", "hermes-agent"))
}
add(filepath.Join(home, ".hermes", "hermes-agent"))
default:
add(filepath.Join(home, ".hermes", "hermes-agent"))
if hermesGOOS == "linux" {
add(filepath.Join(string(filepath.Separator), "usr", "local", "lib", "hermes-agent"))
}
}
}
seen := make(map[string]bool, len(installRoots))
releaseRoots := make([]string, 0, len(installRoots))
for _, root := range installRoots {
releaseRoot := filepath.Join(root, "apps", "desktop", "release")
if seen[releaseRoot] {
continue
}
seen[releaseRoot] = true
releaseRoots = append(releaseRoots, releaseRoot)
}
return releaseRoots
}
func hermesDesktopPackagedExecutableCandidates(releaseRoot string) []string {
switch hermesGOOS {
case "darwin":
matches, err := filepath.Glob(filepath.Join(releaseRoot, "mac*", "Hermes.app", "Contents", "MacOS", "Hermes"))
if err != nil {
return nil
}
return matches
case "windows":
return []string{
filepath.Join(releaseRoot, "win-unpacked", "Hermes.exe"),
filepath.Join(releaseRoot, "win-ia32-unpacked", "Hermes.exe"),
filepath.Join(releaseRoot, "win-arm64-unpacked", "Hermes.exe"),
}
default:
return []string{
filepath.Join(releaseRoot, "linux-unpacked", "hermes"),
filepath.Join(releaseRoot, "linux-unpacked", "Hermes"),
}
}
}
func hermesDesktopHasFlag(args []string, names ...string) bool {
for _, arg := range args {
if arg == "--" {
return false
}
for _, name := range names {
if arg == name {
return true
}
}
}
return false
}
func (h *Hermes) Paths() []string {
configPath, err := hermesConfigPath()
if err != nil {
@@ -183,22 +317,24 @@ func (h *Hermes) installed() bool {
}
func (h *Hermes) ensureInstalled() error {
return h.ensureInstalledFor("hermes")
}
func (h *Hermes) ensureInstalledFor(command string) error {
if h.installed() {
return nil
}
if hermesGOOS == "windows" {
return hermesWindowsHint()
}
var missing []string
for _, dep := range []string{"bash", "curl", "git"} {
if _, err := hermesLookPath(dep); err != nil {
missing = append(missing, dep)
if hermesGOOS != "windows" {
for _, dep := range []string{"bash", "curl", "git"} {
if _, err := hermesLookPath(dep); err != nil {
missing = append(missing, dep)
}
}
}
if len(missing) > 0 {
return fmt.Errorf("Hermes is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch hermes", strings.Join(missing, "\n "))
return fmt.Errorf("Hermes is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch %s", strings.Join(missing, "\n "), command)
}
ok, err := ConfirmPrompt("Hermes is not installed. Install now?")
@@ -210,7 +346,7 @@ func (h *Hermes) ensureInstalled() error {
}
fmt.Fprintf(os.Stderr, "\nInstalling Hermes...\n")
if err := hermesAttachedCommand("bash", "-lc", hermesInstallScript).Run(); err != nil {
if err := h.runInstallScript(); err != nil {
return fmt.Errorf("failed to install hermes: %w", err)
}
@@ -222,6 +358,13 @@ func (h *Hermes) ensureInstalled() error {
return nil
}
func (h *Hermes) runInstallScript() error {
if hermesGOOS == "windows" {
return hermesAttachedCommand("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", hermesWindowsInstallCmd).Run()
}
return hermesAttachedCommand("bash", "-lc", hermesInstallScript).Run()
}
func (h *Hermes) listModels(defaultModel string) []string {
client := hermesOllamaClient()
resp, err := client.List(context.Background())
@@ -259,7 +402,12 @@ func (h *Hermes) binary() (string, error) {
}
if hermesGOOS == "windows" {
return "", hermesWindowsHint()
for _, fallback := range hermesWindowsBinaryFallbacks() {
if _, err := os.Stat(fallback); err == nil {
return fallback, nil
}
}
return "", fmt.Errorf("hermes is not installed")
}
home, err := hermesUserHome()
@@ -274,12 +422,63 @@ func (h *Hermes) binary() (string, error) {
return "", fmt.Errorf("hermes is not installed")
}
func hermesConfigPath() (string, error) {
func hermesWindowsBinaryFallbacks() []string {
var roots []string
add := func(root string) {
root = strings.TrimSpace(root)
if root != "" {
roots = append(roots, filepath.Clean(root))
}
}
add(os.Getenv("HERMES_HOME"))
add(os.Getenv("LOCALAPPDATA"))
if home, err := hermesUserHome(); err == nil {
add(filepath.Join(home, "AppData", "Local"))
}
seen := make(map[string]bool, len(roots))
var fallbacks []string
for _, root := range roots {
if seen[root] {
continue
}
seen[root] = true
fallbacks = append(fallbacks, filepath.Join(root, "hermes-agent", "venv", "Scripts", "hermes.exe"))
if filepath.Base(root) != "hermes" {
fallbacks = append(fallbacks, filepath.Join(root, "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe"))
}
}
return fallbacks
}
func hermesHomePath() (string, error) {
if hermesHome := strings.TrimSpace(os.Getenv("HERMES_HOME")); hermesHome != "" {
return filepath.Clean(hermesHome), nil
}
if hermesGOOS == "windows" {
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
return filepath.Join(localAppData, "hermes"), nil
}
home, err := hermesUserHome()
if err != nil {
return "", err
}
return filepath.Join(home, "AppData", "Local", "hermes"), nil
}
home, err := hermesUserHome()
if err != nil {
return "", err
}
return filepath.Join(home, ".hermes", "config.yaml"), nil
return filepath.Join(home, ".hermes"), nil
}
func hermesConfigPath() (string, error) {
home, err := hermesHomePath()
if err != nil {
return "", err
}
return filepath.Join(home, "config.yaml"), nil
}
func hermesBaseURL() string {
@@ -287,11 +486,11 @@ func hermesBaseURL() string {
}
func hermesEnvPath() (string, error) {
home, err := hermesUserHome()
home, err := hermesHomePath()
if err != nil {
return "", err
}
return filepath.Join(home, ".hermes", ".env"), nil
return filepath.Join(home, ".env"), nil
}
func (h *Hermes) runGatewaySetupPreflight(args []string, runSetup func() error) error {
@@ -671,9 +870,3 @@ func hermesAttachedCommand(name string, args ...string) *exec.Cmd {
cmd.Stderr = os.Stderr
return cmd
}
func hermesWindowsHint() error {
return fmt.Errorf("Hermes on Windows requires WSL2. Install WSL with: wsl --install\n" +
"Then run 'ollama launch hermes' from inside your WSL shell.\n" +
"Docs: https://hermes-agent.nousresearch.com/docs/getting-started/installation/")
}
+244 -13
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
@@ -65,6 +66,20 @@ func clearHermesMessagingEnvVars(t *testing.T) {
}
}
func clearHermesDesktopPackageEnvVars(t *testing.T) {
t.Helper()
for _, key := range []string{"HERMES_INSTALL_DIR", "HERMES_HOME", "LOCALAPPDATA"} {
if value, ok := os.LookupEnv(key); ok {
t.Setenv(key, value)
} else {
t.Setenv(key, "")
}
if err := os.Unsetenv(key); err != nil {
t.Fatalf("unset %s: %v", key, err)
}
}
}
func TestHermesIntegration(t *testing.T) {
h := &Hermes{}
@@ -408,19 +423,36 @@ func TestHermesConfigureMigratesLegacyManagedAliases(t *testing.T) {
func TestHermesPathsUsesLocalConfigPathForNativeWindowsHermes(t *testing.T) {
tmpDir := t.TempDir()
winHome := filepath.Join(tmpDir, "winhome")
localAppData := filepath.Join(tmpDir, "LocalAppData")
setTestHome(t, winHome)
withHermesPlatform(t, "windows")
withHermesUserHome(t, winHome)
t.Setenv("PATH", tmpDir)
t.Setenv("LOCALAPPDATA", localAppData)
writeFakeBinary(t, tmpDir, "hermes")
got := (&Hermes{}).Paths()
want := filepath.Join(winHome, ".hermes", "config.yaml")
want := filepath.Join(localAppData, "hermes", "config.yaml")
if len(got) != 1 || got[0] != want {
t.Fatalf("expected local config path %q, got %v", want, got)
}
}
func TestHermesPathsUsesHermesHomeOverride(t *testing.T) {
tmpDir := t.TempDir()
hermesHome := filepath.Join(tmpDir, "custom-hermes-home")
setTestHome(t, filepath.Join(tmpDir, "home"))
withHermesPlatform(t, "windows")
t.Setenv("HERMES_HOME", hermesHome)
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
got := (&Hermes{}).Paths()
want := filepath.Join(hermesHome, "config.yaml")
if len(got) != 1 || got[0] != want {
t.Fatalf("expected HERMES_HOME config path %q, got %v", want, got)
}
}
func TestHermesCurrentModelRequiresHealthyManagedConfig(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -565,6 +597,172 @@ func TestHermesRunPassthroughArgs(t *testing.T) {
}
}
func writeHermesDesktopPackage(t *testing.T, home string) {
t.Helper()
writeHermesDesktopExecutable(t,
filepath.Join(home, ".hermes", "hermes-agent", "apps", "desktop", "release"),
hermesDesktopTestExecutableRelativePath(hermesGOOS),
)
}
func writeHermesDesktopExecutable(t *testing.T, releaseRoot, relative string) {
t.Helper()
path := filepath.Join(releaseRoot, relative)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
}
func hermesDesktopTestExecutableRelativePath(goos string) string {
switch goos {
case "darwin":
return filepath.Join("mac-arm64", "Hermes.app", "Contents", "MacOS", "Hermes")
case "windows":
return filepath.Join("win-unpacked", "Hermes.exe")
default:
return filepath.Join("linux-unpacked", "hermes")
}
}
func writeHermesDesktopTestBinary(t *testing.T, dir string) {
t.Helper()
bin := filepath.Join(dir, "hermes")
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
func readHermesDesktopInvocations(t *testing.T, home string) string {
t.Helper()
data, err := os.ReadFile(filepath.Join(home, "hermes-invocations.log"))
if err != nil {
t.Fatal(err)
}
return strings.TrimSpace(string(data))
}
func TestHermesDesktopRun(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tests := []struct {
name string
goos string
args []string
hasPackage bool
clearPkgEnv bool
want string
}{
{
name: "desktop subcommand",
goos: "darwin",
args: []string{"--foreground"},
clearPkgEnv: true,
want: "[desktop --foreground]",
},
{
name: "skip build when packaged app exists",
goos: runtime.GOOS,
args: []string{"--cwd", "/tmp/project"},
hasPackage: true,
want: "[desktop --skip-build --cwd /tmp/project]",
},
{
name: "explicit skip build",
goos: runtime.GOOS,
args: []string{"--skip-build"},
hasPackage: true,
want: "[desktop --skip-build]",
},
{
name: "source mode",
goos: runtime.GOOS,
args: []string{"--source"},
hasPackage: true,
want: "[desktop --source]",
},
{
name: "build only",
goos: runtime.GOOS,
args: []string{"--build-only"},
hasPackage: true,
want: "[desktop --build-only]",
},
{
name: "help",
goos: runtime.GOOS,
args: []string{"--help"},
hasPackage: true,
want: "[desktop --help]",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withLauncherHooks(t)
withInteractiveSession(t, true)
withHermesPlatform(t, tt.goos)
clearHermesMessagingEnvVars(t)
if tt.clearPkgEnv {
clearHermesDesktopPackageEnvVars(t)
}
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
if tt.hasPackage {
writeHermesDesktopPackage(t, tmpDir)
}
writeHermesDesktopTestBinary(t, tmpDir)
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
t.Fatalf("did not expect messaging prompt during desktop launch: %s", prompt)
return false, nil
}
if err := (&HermesDesktop{}).Run("", nil, tt.args); err != nil {
t.Fatalf("Run returned error: %v", err)
}
if got := readHermesDesktopInvocations(t, tmpDir); got != tt.want {
t.Fatalf("expected %q, got %q", tt.want, got)
}
})
}
}
func TestHermesDesktopRunUsesWindowsLocalAppDataPackage(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withHermesPlatform(t, "windows")
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
writeHermesDesktopExecutable(t,
filepath.Join(tmpDir, "LocalAppData", "hermes", "hermes-agent", "apps", "desktop", "release"),
hermesDesktopTestExecutableRelativePath("windows"),
)
got := (&HermesDesktop{}).launchArgs([]string{"--cwd", `C:\Users\me\project`})
want := []string{"desktop", "--skip-build", "--cwd", `C:\Users\me\project`}
if diff := compareStrings(got, want); diff != "" {
t.Fatalf("Hermes Desktop launch args mismatch: %s", diff)
}
}
func TestHermesDesktopReleaseRootsIncludeLinuxRootInstall(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withHermesPlatform(t, "linux")
got := hermesDesktopReleaseRoots()
want := filepath.Join(string(filepath.Separator), "usr", "local", "lib", "hermes-agent", "apps", "desktop", "release")
if !slices.Contains(got, want) {
t.Fatalf("expected Linux root install release path %q in %v", want, got)
}
}
func TestHermesRun_PromptsForMessagingSetupBeforeDefaultLaunch(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
@@ -943,26 +1141,59 @@ func TestHermesMessagingConfiguredRecognizesSupportedGatewayVars(t *testing.T) {
}
}
func TestHermesEnsureInstalledWindowsShowsWSLGuidance(t *testing.T) {
func TestHermesEnsureInstalledWindowsRunsPowerShellInstaller(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses a POSIX shell test binary")
}
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
withLauncherHooks(t)
withHermesPlatform(t, "windows")
t.Setenv("PATH", tmpDir)
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "AppData", "Local"))
powershell := filepath.Join(tmpDir, "powershell.exe")
script := fmt.Sprintf(`#!/bin/sh
printf '%%s\n' "$*" >> %q
/bin/mkdir -p %q
/bin/cat > %q <<'EOS'
#!/bin/sh
exit 0
EOS
/bin/chmod +x %q
exit 0
`,
filepath.Join(tmpDir, "powershell.log"),
filepath.Dir(filepath.Join(tmpDir, "AppData", "Local", "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe")),
filepath.Join(tmpDir, "AppData", "Local", "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe"),
filepath.Join(tmpDir, "AppData", "Local", "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe"),
)
if err := os.WriteFile(powershell, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
if prompt != "Hermes is not installed. Install now?" {
t.Fatalf("unexpected install prompt %q", prompt)
}
return true, nil
}
h := &Hermes{}
err := h.ensureInstalled()
if err == nil {
t.Fatal("expected WSL guidance error")
if err := h.ensureInstalled(); err != nil {
t.Fatalf("ensureInstalled returned error: %v", err)
}
msg := err.Error()
if !strings.Contains(msg, "wsl --install") {
t.Fatalf("expected install command in guidance, got %v", err)
data, err := os.ReadFile(filepath.Join(tmpDir, "powershell.log"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(msg, "hermes-agent.nousresearch.com") {
t.Fatalf("expected docs link in guidance, got %v", err)
}
if strings.Contains(msg, "hermes is not installed") {
t.Fatalf("guidance should not lead with 'hermes is not installed', got %v", err)
logs := string(data)
for _, want := range []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", hermesWindowsInstallURL, "-SkipSetup"} {
if !strings.Contains(logs, want) {
t.Fatalf("expected PowerShell installer args to contain %q, got logs:\n%s", want, logs)
}
}
}
+26 -5
View File
@@ -61,9 +61,11 @@ func TestIntegrationLookup(t *testing.T) {
{"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"},
{"hermes desktop", "hermes-desktop", true, "Hermes Desktop"},
{"kimi", "kimi", true, "Kimi Code CLI"},
{"droid", "droid", true, "Droid"},
{"opencode", "opencode", true, "OpenCode"},
{"omp", "omp", true, "OMP"},
{"pool", "pool", true, "Pool"},
{"unknown integration", "unknown", false, ""},
{"empty string", "", false, ""},
@@ -83,7 +85,7 @@ func TestIntegrationLookup(t *testing.T) {
}
func TestIntegrationRegistry(t *testing.T) {
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "pool"}
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
for _, name := range expectedIntegrations {
t.Run(name, func(t *testing.T) {
r, ok := integrations[name]
@@ -100,7 +102,7 @@ func TestIntegrationRegistry(t *testing.T) {
func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
for _, info := range ListIntegrationInfos() {
switch info.Name {
case "cline", "vscode", "kimi":
case "vscode", "kimi":
t.Fatalf("hidden integration %q should not appear in ListIntegrationInfos", info.Name)
}
}
@@ -1760,6 +1762,11 @@ func TestIntegration_InstallHint(t *testing.T) {
input: "unknown",
wantEmpty: true,
},
{
name: "qwen uses official install page",
input: "qwen",
wantURL: "https://qwen.ai/qwencode",
},
{
name: "empty name has no hint",
input: "",
@@ -1841,9 +1848,9 @@ func TestListIntegrationInfos(t *testing.T) {
for _, info := range infos {
got = append(got, info.Name)
}
wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw"}
wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
if codexAppSupported() != nil {
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode"}
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
}
if len(got) < len(wantPrefix) {
t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got)
@@ -1865,7 +1872,7 @@ func TestListIntegrationInfos(t *testing.T) {
})
t.Run("includes known integrations", func(t *testing.T) {
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false, "omp": false}
if codexAppSupported() == nil {
known["codex-app"] = false
}
@@ -1893,6 +1900,15 @@ func TestListIntegrationInfos(t *testing.T) {
t.Fatal("expected hermes to be included in ListIntegrationInfos")
})
t.Run("includes hermes desktop", func(t *testing.T) {
for _, info := range infos {
if info.Name == "hermes-desktop" {
return
}
}
t.Fatal("expected hermes-desktop to be included in ListIntegrationInfos")
})
t.Run("hermes still resolves explicitly", func(t *testing.T) {
name, runner, err := LookupIntegration("hermes")
if err != nil {
@@ -1991,6 +2007,7 @@ func TestIntegration_Editor(t *testing.T) {
{"claude", false},
{"claude-desktop", false},
{"codex", false},
{"omp", false},
{"nonexistent", false},
}
for _, tt := range tests {
@@ -2015,10 +2032,14 @@ func TestIntegration_AutoInstallable(t *testing.T) {
{"openclaw", true},
{"pi", true},
{"hermes", true},
{"hermes-desktop", true},
{"cline", true},
{"qwen", true},
{"claude", false},
{"claude-desktop", false},
{"codex", false},
{"opencode", false},
{"omp", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+17 -4
View File
@@ -204,6 +204,12 @@ type RestoreSuccessIntegration interface {
RestoreSuccessMessage() string
}
// RestoreInstallCheckSkipper lets cleanup-only restore flows run even when the
// external integration binary has already been removed.
type RestoreInstallCheckSkipper interface {
SkipRestoreInstallCheck() bool
}
// ManagedRuntimeRefresher lets managed integrations refresh any long-lived
// background runtime after launch rewrites their config.
type ManagedRuntimeRefresher interface {
@@ -286,12 +292,15 @@ Supported integrations:
openclaw OpenClaw (aliases: clawdbot, moltbot)
opencode OpenCode
codex Codex
hermes-desktop Hermes Desktop
copilot Copilot CLI (aliases: copilot-cli)
omp OMP
droid Droid
kimi Kimi Code CLI
pi Pi
pool Pool
cline Cline
qwen Qwen Code
vscode VS Code (aliases: code)
Examples:
@@ -301,8 +310,9 @@ Examples:
ollama launch codex-app
ollama launch codex-app --restore
ollama launch hermes
ollama launch hermes-desktop
ollama launch droid --config (does not auto-launch)
ollama launch codex -- -p myprofile (pass extra args to integration)
ollama launch codex --restore
ollama launch codex -- --sandbox workspace-write`,
Args: cobra.ArbitraryArgs,
PreRunE: func(cmd *cobra.Command, args []string) error {
@@ -526,8 +536,10 @@ func restoreIntegration(name string, runner Runner, req IntegrationLaunchRequest
if !ok {
return fmt.Errorf("%s does not support --restore", name)
}
if err := EnsureIntegrationInstalled(name, runner); err != nil {
return err
if skipper, ok := runner.(RestoreInstallCheckSkipper); !ok || !skipper.SkipRestoreInstallCheck() {
if err := EnsureIntegrationInstalled(name, runner); err != nil {
return err
}
}
if err := restorable.Restore(); err != nil {
return err
@@ -752,7 +764,8 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
}
var launchModels []LaunchModel
if (needsConfigure || req.ModelOverride != "") && !savedMatchesModels(saved, models) {
liveConfigMatches := slices.Equal(editor.Models(), models)
if needsConfigure || req.ModelOverride != "" || !savedMatchesModels(saved, models) || !liveConfigMatches {
launchModels = c.modelInventory().Resolve(ctx, models)
if err := prepareEditorIntegration(name, editor, launchModels); err != nil {
return err
+227 -7
View File
@@ -17,10 +17,12 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
)
type launcherEditorRunner struct {
paths []string
models []string
edited [][]string
ranModel string
}
@@ -35,11 +37,14 @@ func (r *launcherEditorRunner) String() string { return "LauncherEditor" }
func (r *launcherEditorRunner) Paths() []string { return r.paths }
func (r *launcherEditorRunner) Edit(models []LaunchModel) error {
r.edited = append(r.edited, launchModelNames(models))
names := launchModelNames(models)
r.edited = append(r.edited, names)
return nil
}
func (r *launcherEditorRunner) Models() []string { return nil }
func (r *launcherEditorRunner) Models() []string {
return append([]string(nil), r.models...)
}
type launcherSingleRunner struct {
ranModel string
@@ -530,6 +535,81 @@ func TestLaunchIntegration_ManagedSingleIntegrationPrintsConfigurationSuccessAft
}
}
func TestLaunchIntegration_QwenConfiguresSingleModel(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/experimental/model-recommendations":
fmt.Fprint(w, `{"recommendations":[]}`)
case "/api/tags":
fmt.Fprint(w, `{"models":[{"name":"gemma4"}]}`)
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)
binDir := filepath.Join(tmpDir, "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatalf("failed to create bin dir: %v", err)
}
writeFakeBinary(t, binDir, "qwen")
t.Setenv("PATH", binDir)
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
return "gemma4", nil
}
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return true, nil
}
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "qwen",
ConfigureOnly: true,
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, ".qwen", "settings.json"))
if err != nil {
t.Fatalf("failed to read qwen config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("failed to parse qwen config: %v", err)
}
modelCfg := cfg["model"].(map[string]any)
if modelCfg["name"] != "gemma4" {
t.Fatalf("expected model.name gemma4, got %v", modelCfg["name"])
}
modelProviders := cfg["modelProviders"].(map[string]any)
openai := modelProviders["openai"].([]any)
if len(openai) != 1 {
t.Fatalf("expected one provider, got %d", len(openai))
}
saved, err := config.LoadIntegration("qwen")
if err != nil {
t.Fatalf("failed to reload qwen integration config: %v", err)
}
if diff := compareStrings(saved.Models, []string{"gemma4"}); diff != "" {
t.Fatalf("saved models mismatch: %s", diff)
}
if !saved.Onboarded {
t.Fatal("expected qwen integration to be marked onboarded")
}
}
func TestLaunchIntegration_ManagedSingleIntegrationDoesNotPrintRestoreHintWhenUnchanged(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
@@ -2078,7 +2158,11 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}}
settingsPath := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(settingsPath, []byte("{}"), 0o644); err != nil {
t.Fatalf("failed to seed editor settings: %v", err)
}
editor := &launcherEditorRunner{paths: []string{settingsPath}}
withIntegrationOverride(t, "droid", editor)
var multiCalled bool
@@ -2129,6 +2213,86 @@ func TestLaunchIntegration_EditorForceConfigure(t *testing.T) {
}
}
func TestLaunchIntegration_ClineRewritesWhenLiveProviderDrifted(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "cline")
t.Setenv("PATH", binDir)
if err := config.SaveIntegration("cline", []string{"llama3.2"}); err != nil {
t.Fatalf("failed to seed saved config: %v", err)
}
providersPath := clineProvidersPath(tmpDir)
if err := os.MkdirAll(filepath.Dir(providersPath), 0o755); err != nil {
t.Fatalf("failed to create providers dir: %v", err)
}
existingProviders := map[string]any{
"version": float64(1),
"lastUsedProvider": "openai-codex-cli",
"providers": map[string]any{
"openai-codex-cli": map[string]any{
"settings": map[string]any{
"provider": "openai-codex-cli",
"model": "gpt-5.5",
"reasoning": "medium",
},
"updatedAt": "2026-06-01T12:00:00Z",
"tokenSource": "manual",
},
},
}
data, _ := json.Marshal(existingProviders)
if err := os.WriteFile(providersPath, data, 0o644); err != nil {
t.Fatalf("failed to seed providers config: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/show":
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
fmt.Fprintf(w, `{"model":%q}`, req.Model)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
Name: "cline",
}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
providersConfig, err := fileutil.ReadJSON(providersPath)
if err != nil {
t.Fatalf("failed to read providers config: %v", err)
}
if providersConfig["lastUsedProvider"] != clineLaunchProvider {
t.Fatalf("lastUsedProvider = %v, want %s", providersConfig["lastUsedProvider"], clineLaunchProvider)
}
providers, _ := providersConfig["providers"].(map[string]any)
if _, ok := providers["openai-codex-cli"]; !ok {
t.Fatal("expected existing openai-codex-cli provider to be preserved")
}
ollamaProvider, _ := providers[clineLaunchProvider].(map[string]any)
settings, _ := ollamaProvider["settings"].(map[string]any)
if settings["provider"] != clineLaunchProvider {
t.Fatalf("ollama settings.provider = %v, want %s", settings["provider"], clineLaunchProvider)
}
if settings["model"] != "llama3.2" {
t.Fatalf("ollama settings.model = %v, want llama3.2", settings["model"])
}
if settings["baseUrl"] != srv.URL+"/v1" {
t.Fatalf("ollama settings.baseUrl = %v, want %s/v1", settings["baseUrl"], srv.URL)
}
}
func TestLaunchIntegration_EditorForceConfigure_FloatsCheckedModelsInPicker(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
@@ -2138,7 +2302,7 @@ func TestLaunchIntegration_EditorForceConfigure_FloatsCheckedModelsInPicker(t *t
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{}
editor := &launcherEditorRunner{models: []string{"llama3.2", "missing-local"}}
withIntegrationOverride(t, "droid", editor)
if err := config.SaveIntegration("droid", []string{"qwen3.5:cloud", "qwen3.5"}); err != nil {
@@ -2713,7 +2877,7 @@ func TestLaunchIntegration_ConfiguredEditorLaunchValidatesPrimaryOnly(t *testing
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{}
editor := &launcherEditorRunner{models: []string{"llama3.2", "missing-local"}}
withIntegrationOverride(t, "droid", editor)
if err := config.SaveIntegration("droid", []string{"llama3.2", "missing-local"}); err != nil {
@@ -2778,7 +2942,11 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T)
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{paths: []string{"/tmp/settings.json"}}
settingsPath := filepath.Join(t.TempDir(), "settings.json")
if err := os.WriteFile(settingsPath, []byte("{}"), 0o644); err != nil {
t.Fatalf("failed to seed editor settings: %v", err)
}
editor := &launcherEditorRunner{paths: []string{settingsPath}, models: []string{"llama3.2", "qwen3:8b"}}
withIntegrationOverride(t, "droid", editor)
if err := config.SaveIntegration("droid", []string{"llama3.2", "qwen3:8b"}); err != nil {
@@ -2821,6 +2989,58 @@ func TestLaunchIntegration_ConfiguredEditorLaunchSkipsReconfigure(t *testing.T)
}
}
func TestLaunchIntegration_ConfiguredEditorLaunchRewritesDriftedLiveConfig(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
withLauncherHooks(t)
binDir := t.TempDir()
writeFakeBinary(t, binDir, "droid")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{models: []string{"qwen3:8b"}}
withIntegrationOverride(t, "droid", editor)
if err := config.SaveIntegration("droid", []string{"llama3.2", "mistral"}); err != nil {
t.Fatalf("failed to seed config: %v", err)
}
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
t.Fatalf("did not expect prompt during a normal editor launch: %s", prompt)
return false, nil
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/show" {
var req apiShowRequest
_ = json.NewDecoder(r.Body).Decode(&req)
fmt.Fprintf(w, `{"model":%q}`, req.Model)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
t.Setenv("OLLAMA_HOST", srv.URL)
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "droid"}); err != nil {
t.Fatalf("LaunchIntegration returned error: %v", err)
}
if diff := cmp.Diff([][]string{{"llama3.2", "mistral"}}, editor.edited); diff != "" {
t.Fatalf("expected editor config rewrite when live config drifts (-want +got):\n%s", diff)
}
if editor.ranModel != "llama3.2" {
t.Fatalf("expected launch to use saved primary model, got %q", editor.ranModel)
}
saved, err := config.LoadIntegration("droid")
if err != nil {
t.Fatalf("failed to reload saved config: %v", err)
}
if diff := compareStrings(saved.Models, []string{"llama3.2", "mistral"}); diff != "" {
t.Fatalf("unexpected saved models (-want +got):\n%s", diff)
}
}
func TestLaunchIntegration_OpenclawPreservesExistingModelList(t *testing.T) {
tmpDir := t.TempDir()
setLaunchTestHome(t, tmpDir)
@@ -2830,7 +3050,7 @@ func TestLaunchIntegration_OpenclawPreservesExistingModelList(t *testing.T) {
writeFakeBinary(t, binDir, "openclaw")
t.Setenv("PATH", binDir)
editor := &launcherEditorRunner{}
editor := &launcherEditorRunner{models: []string{"llama3.2", "mistral"}}
withIntegrationOverride(t, "openclaw", editor)
if err := config.SaveIntegration("openclaw", []string{"llama3.2", "mistral"}); err != nil {
-11
View File
@@ -496,17 +496,6 @@ 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]
+454
View File
@@ -0,0 +1,454 @@
package launch
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
"github.com/ollama/ollama/types/model"
"gopkg.in/yaml.v3"
)
const (
ompIntegrationName = "omp"
ompProviderName = "ollama"
ompSetupVersion = 1
ompWebSearchPlugin = "@ollama/pi-web-search"
)
// OMP implements Runner for the OMP coding-agent integration.
type OMP struct{}
func (o *OMP) String() string { return "OMP" }
func (o *OMP) Paths() []string {
var paths []string
for _, pathFn := range []func() (string, error){ompModelsPath, ompConfigPath} {
path, err := pathFn()
if err != nil {
continue
}
if _, err := os.Stat(path); err == nil {
paths = append(paths, path)
}
}
return paths
}
func (o *OMP) Configure(model string) error {
return o.ConfigureWithModels(model, []LaunchModel{fallbackLaunchModel(model)})
}
func (o *OMP) ConfigureWithModels(primary string, models []LaunchModel) error {
if primary == "" {
return nil
}
if len(models) == 0 {
models = []LaunchModel{fallbackLaunchModel(primary)}
}
if err := writeOMPModelsConfig(primary, models); err != nil {
return err
}
return writeOMPAgentConfig()
}
func (o *OMP) CurrentModel() string {
cfg, err := readOMPModelsConfig()
if err != nil {
return ""
}
provider, ok := ompProvider(cfg)
if !ok {
return ""
}
if !ompProviderHealthy(provider) {
return ""
}
models, _ := provider["models"].([]any)
for _, raw := range models {
entry, ok := raw.(map[string]any)
if !ok {
continue
}
if id, _ := entry["id"].(string); id != "" {
return id
}
}
return ""
}
func (o *OMP) Onboard() error {
return config.MarkIntegrationOnboarded(ompIntegrationName)
}
func (o *OMP) RequiresInteractiveOnboarding() bool { return false }
func (o *OMP) args(model string, extra []string) []string {
var args []string
if model != "" {
args = append(args, "--model", ompModelName(model))
}
args = append(args, extra...)
return args
}
func ompModelName(model string) string {
if strings.HasPrefix(model, "ollama/") {
return model
}
return "ollama/" + model
}
func (o *OMP) findPath() (string, error) {
if p, err := exec.LookPath("omp"); err == nil {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
for _, dir := range []string{
filepath.Join(home, ".local", "bin"),
filepath.Join(home, ".bun", "bin"),
} {
for _, name := range ompExecutableNames() {
fallback := filepath.Join(dir, name)
if _, err := os.Stat(fallback); err == nil {
return fallback, nil
}
}
}
return "", exec.ErrNotFound
}
func ompExecutableNames() []string {
if runtime.GOOS == "windows" {
return []string{"omp.exe", "omp.cmd", "omp.bat"}
}
return []string{"omp"}
}
func (o *OMP) Run(model string, _ []LaunchModel, args []string) error {
ompPath, err := o.findPath()
if err != nil {
return fmt.Errorf("omp is not installed, install from https://omp.sh")
}
ensureOMPWebSearchPlugin(ompPath)
cmd := exec.Command(ompPath, o.args(model, args)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = os.Environ()
return cmd.Run()
}
func ensureOMPWebSearchPlugin(bin string) {
if !shouldManageOllamaWebSearch() {
fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, ompWebSearchPlugin, ansiReset)
return
}
fmt.Fprintf(os.Stderr, "%sChecking OMP web search plugin...%s\n", ansiGray, ansiReset)
installed, err := ompPluginInstalled(bin, ompWebSearchPlugin)
if err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, ompWebSearchPlugin, err, ansiReset)
return
}
verb := "Installing"
warnVerb := "install"
doneVerb := "Installed"
if installed {
verb = "Updating"
warnVerb = "update"
doneVerb = "Updated"
}
fmt.Fprintf(os.Stderr, "%s%s %s...%s\n", ansiGray, verb, ompWebSearchPlugin, ansiReset)
cmd := exec.Command(bin, "plugin", "install", ompWebSearchPlugin)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: could not %s %s: %v%s\n", ansiYellow, warnVerb, ompWebSearchPlugin, err, ansiReset)
return
}
fmt.Fprintf(os.Stderr, "%s ✓ %s %s%s\n", ansiGreen, doneVerb, ompWebSearchPlugin, ansiReset)
}
func ompPluginInstalled(bin, plugin string) (bool, error) {
cmd := exec.Command(bin, "plugin", "list")
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
return false, err
}
return false, fmt.Errorf("%w: %s", err, msg)
}
versioned := plugin + "@"
for _, line := range strings.Split(string(out), "\n") {
trimmed := strings.TrimSpace(line)
if strings.Contains(trimmed, versioned) || trimmed == plugin {
return true, nil
}
}
return false, nil
}
func ompModelsPath() (string, error) {
dir, err := ompAgentDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "models.yml"), nil
}
func ompConfigPath() (string, error) {
dir, err := ompAgentDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "config.yml"), nil
}
func ompAgentDir() (string, error) {
if dir := strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")); dir != "" {
return dir, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
configDir := strings.TrimSpace(os.Getenv("PI_CONFIG_DIR"))
if configDir == "" {
configDir = ".omp"
}
if filepath.IsAbs(configDir) {
return filepath.Join(configDir, "agent"), nil
}
return filepath.Join(home, configDir, "agent"), nil
}
func readOMPModelsConfig() (map[string]any, error) {
path, err := ompModelsPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg == nil {
cfg = make(map[string]any)
}
return cfg, nil
}
func writeOMPModelsConfig(primary string, models []LaunchModel) error {
path, err := ompModelsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
cfg := make(map[string]any)
if existing, err := readOMPModelsConfig(); err == nil {
cfg = existing
}
provider := ensureOMPProvider(cfg)
existingByID := ompModelEntriesByID(provider)
ordered := append([]LaunchModel(nil), models...)
if model, ok := findLaunchModel(ordered, primary); ok {
ordered = append([]LaunchModel{model}, removeLaunchModel(ordered, primary)...)
} else {
ordered = append([]LaunchModel{fallbackLaunchModel(primary)}, ordered...)
}
var merged []any
seen := make(map[string]bool, len(ordered))
for _, model := range ordered {
if model.Name == "" || seen[model.Name] {
continue
}
seen[model.Name] = true
entry := ompModelConfig(model)
if existing, ok := existingByID[model.Name]; ok {
for key, value := range existing {
if _, overridden := entry[key]; !overridden {
entry[key] = value
}
}
}
merged = append(merged, entry)
}
for _, raw := range ompProviderModels(provider) {
entry, ok := raw.(map[string]any)
if !ok {
merged = append(merged, raw)
continue
}
id, _ := entry["id"].(string)
if id == "" || seen[id] {
continue
}
merged = append(merged, entry)
}
provider["models"] = merged
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return fileutil.WriteWithBackup(path, data, ompIntegrationName)
}
func writeOMPAgentConfig() error {
path, err := ompConfigPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
cfg := make(map[string]any)
if data, err := os.ReadFile(path); err == nil {
if err := yaml.Unmarshal(data, &cfg); err != nil {
return err
}
if cfg == nil {
cfg = make(map[string]any)
}
}
cfg["setupVersion"] = ompSetupVersion
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return fileutil.WriteWithBackup(path, data, ompIntegrationName)
}
func ensureOMPProvider(cfg map[string]any) map[string]any {
providers, _ := cfg["providers"].(map[string]any)
if providers == nil {
providers = make(map[string]any)
cfg["providers"] = providers
}
provider, _ := providers[ompProviderName].(map[string]any)
if provider == nil {
provider = make(map[string]any)
providers[ompProviderName] = provider
}
provider["baseUrl"] = ompBaseURL()
provider["api"] = "openai-responses"
provider["auth"] = "none"
provider["discovery"] = map[string]any{"type": "ollama"}
return provider
}
func ompBaseURL() string {
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1"
}
func ompProviderHealthy(provider map[string]any) bool {
baseURL, _ := provider["baseUrl"].(string)
if strings.TrimRight(baseURL, "/") != strings.TrimRight(ompBaseURL(), "/") {
return false
}
api, _ := provider["api"].(string)
if api != "openai-responses" {
return false
}
auth, _ := provider["auth"].(string)
if auth != "none" {
return false
}
discovery, _ := provider["discovery"].(map[string]any)
if discovery == nil {
return false
}
discoveryType, _ := discovery["type"].(string)
return discoveryType == "ollama"
}
func ompProvider(cfg map[string]any) (map[string]any, bool) {
providers, ok := cfg["providers"].(map[string]any)
if !ok {
return nil, false
}
provider, ok := providers[ompProviderName].(map[string]any)
return provider, ok
}
func ompProviderModels(provider map[string]any) []any {
models, _ := provider["models"].([]any)
return models
}
func ompModelEntriesByID(provider map[string]any) map[string]map[string]any {
out := make(map[string]map[string]any)
for _, raw := range ompProviderModels(provider) {
entry, ok := raw.(map[string]any)
if !ok {
continue
}
if id, _ := entry["id"].(string); id != "" {
out[id] = entry
}
}
return out
}
func ompModelConfig(modelInfo LaunchModel) map[string]any {
entry := map[string]any{
"id": modelInfo.Name,
"name": modelInfo.Name,
}
input := []string{"text"}
if slices.Contains(modelInfo.Capabilities, model.CapabilityVision) {
input = append(input, "image")
}
entry["input"] = input
if modelInfo.ContextLength > 0 {
entry["contextWindow"] = modelInfo.ContextLength
}
if modelInfo.MaxOutputTokens > 0 {
entry["maxTokens"] = modelInfo.MaxOutputTokens
}
return entry
}
func removeLaunchModel(models []LaunchModel, name string) []LaunchModel {
out := make([]LaunchModel, 0, len(models))
for _, model := range models {
if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) {
continue
}
out = append(out, model)
}
return out
}
+687
View File
@@ -0,0 +1,687 @@
package launch
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
modelpkg "github.com/ollama/ollama/types/model"
"gopkg.in/yaml.v3"
)
func TestMain(m *testing.M) {
if os.Getenv("OLLAMA_LAUNCH_OMP_TEST_HELPER") == "1" {
runOMPTestHelper()
return
}
os.Exit(m.Run())
}
func runOMPTestHelper() {
logPath := os.Getenv("OLLAMA_LAUNCH_OMP_TEST_LOG")
if logPath != "" {
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err == nil {
_, _ = fmt.Fprintln(f, strings.Join(os.Args[1:], " "))
_ = f.Close()
}
}
if len(os.Args) >= 3 && os.Args[1] == "plugin" && os.Args[2] == "list" {
fmt.Print(os.Getenv("OLLAMA_LAUNCH_OMP_TEST_PLUGIN_LIST"))
os.Exit(0)
}
if len(os.Args) >= 4 && os.Args[1] == "plugin" && os.Args[2] == "install" {
if os.Getenv("OLLAMA_LAUNCH_OMP_TEST_FAIL_INSTALL") == "1" {
_, _ = fmt.Fprintln(os.Stderr, "install failed")
os.Exit(1)
}
os.Exit(0)
}
os.Exit(0)
}
func setOMPTestHome(t *testing.T, dir string) {
t.Helper()
setTestHome(t, dir)
t.Setenv("PI_CONFIG_DIR", "")
t.Setenv("PI_CODING_AGENT_DIR", "")
}
func TestOMPIntegration(t *testing.T) {
o := &OMP{}
t.Run("String", func(t *testing.T) {
if got := o.String(); got != "OMP" {
t.Errorf("String() = %q, want %q", got, "OMP")
}
})
t.Run("implements Runner", func(t *testing.T) {
var _ Runner = o
})
t.Run("implements ManagedSingleModel", func(t *testing.T) {
var _ ManagedSingleModel = o
})
t.Run("implements ManagedModelListConfigurer", func(t *testing.T) {
var _ ManagedModelListConfigurer = o
})
t.Run("does not require interactive onboarding", func(t *testing.T) {
var _ ManagedInteractiveOnboarding = o
if o.RequiresInteractiveOnboarding() {
t.Fatal("OMP onboarding should not require an interactive terminal")
}
})
}
func TestOMPArgs(t *testing.T) {
o := &OMP{}
tests := []struct {
name string
model string
args []string
want []string
}{
{"with model", "gemma4", nil, []string{"--model", "ollama/gemma4"}},
{"with cloud model", "kimi-k2.6:cloud", nil, []string{"--model", "ollama/kimi-k2.6:cloud"}},
{"empty model", "", nil, nil},
{"with model and extra", "gemma4", []string{"--help"}, []string{"--model", "ollama/gemma4", "--help"}},
{"already qualified", "ollama/gemma4", nil, []string{"--model", "ollama/gemma4"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := o.args(tt.model, tt.args)
if !slices.Equal(got, tt.want) {
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
}
})
}
}
func TestOMPRun_WebSearchPluginLifecycle(t *testing.T) {
seedOMPHelperBinary := func(t *testing.T, dir string) {
t.Helper()
src, err := os.Executable()
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(src)
if err != nil {
t.Fatal(err)
}
dst := filepath.Join(dir, ompExecutableNames()[0])
if err := os.WriteFile(dst, data, 0o755); err != nil {
t.Fatal(err)
}
}
setCloudStatus := func(t *testing.T, disabled bool) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/status" {
fmt.Fprintf(w, `{"cloud":{"disabled":%t,"source":"config"}}`, disabled)
return
}
http.NotFound(w, r)
}))
t.Cleanup(srv.Close)
t.Setenv("OLLAMA_HOST", srv.URL)
}
setup := func(t *testing.T, pluginList string, cloudDisabled bool) (string, *OMP) {
t.Helper()
tmpDir := t.TempDir()
setOMPTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_HELPER", "1")
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_PLUGIN_LIST", pluginList)
logPath := filepath.Join(tmpDir, "omp.log")
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_LOG", logPath)
setCloudStatus(t, cloudDisabled)
seedOMPHelperBinary(t, tmpDir)
return logPath, &OMP{}
}
t.Run("web search missing installs before launch", func(t *testing.T) {
logPath, o := setup(t, "No plugins installed\n", false)
if err := o.Run("kimi-k2.6:cloud", nil, []string{"session"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
calls, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
got := string(calls)
if !strings.Contains(got, "plugin list\n") {
t.Fatalf("expected plugin list call, got:\n%s", got)
}
if !strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") {
t.Fatalf("expected plugin install call, got:\n%s", got)
}
if !strings.Contains(got, "--model ollama/kimi-k2.6:cloud session\n") {
t.Fatalf("expected final omp launch call, got:\n%s", got)
}
})
t.Run("web search present refreshes before launch", func(t *testing.T) {
logPath, o := setup(t, "npm Plugins:\n\n● "+ompWebSearchPlugin+"@0.0.5\n", false)
if err := o.Run("gemma4", nil, []string{"chat"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
calls, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
got := string(calls)
if !strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") {
t.Fatalf("expected plugin refresh install call, got:\n%s", got)
}
if !strings.Contains(got, "--model ollama/gemma4 chat\n") {
t.Fatalf("expected final omp launch call, got:\n%s", got)
}
})
t.Run("web search install failure warns and continues", func(t *testing.T) {
logPath, o := setup(t, "No plugins installed\n", false)
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_FAIL_INSTALL", "1")
stderr := captureStderr(t, func() {
if err := o.Run("gemma4", nil, []string{"chat"}); err != nil {
t.Fatalf("Run() should continue after plugin install failure, got %v", err)
}
})
if !strings.Contains(stderr, "Warning: could not install "+ompWebSearchPlugin) {
t.Fatalf("expected install warning, got:\n%s", stderr)
}
calls, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(calls), "--model ollama/gemma4 chat\n") {
t.Fatalf("expected final omp launch call, got:\n%s", calls)
}
})
t.Run("cloud disabled skips web search plugin management", func(t *testing.T) {
logPath, o := setup(t, "No plugins installed\n", true)
stderr := captureStderr(t, func() {
if err := o.Run("gemma4", nil, []string{"chat"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
})
if !strings.Contains(stderr, "Cloud is disabled; skipping "+ompWebSearchPlugin+" setup.") {
t.Fatalf("expected cloud-disabled skip message, got:\n%s", stderr)
}
calls, err := os.ReadFile(logPath)
if err != nil {
t.Fatal(err)
}
got := string(calls)
if strings.Contains(got, "plugin list\n") || strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") {
t.Fatalf("did not expect plugin management calls, got:\n%s", got)
}
if !strings.Contains(got, "--model ollama/gemma4 chat\n") {
t.Fatalf("expected final omp launch call, got:\n%s", got)
}
})
}
func TestOMPFindPath(t *testing.T) {
o := &OMP{}
t.Run("finds omp in PATH", func(t *testing.T) {
tmpDir := t.TempDir()
name := "omp"
if runtime.GOOS == "windows" {
name = "omp.exe"
}
fakeBin := filepath.Join(tmpDir, name)
os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0o755)
t.Setenv("PATH", tmpDir)
got, err := o.findPath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != fakeBin {
t.Errorf("findPath() = %q, want %q", got, fakeBin)
}
})
t.Run("falls back to ~/.local/bin/omp", func(t *testing.T) {
home := t.TempDir()
setOMPTestHome(t, home)
t.Setenv("PATH", t.TempDir())
fallback := filepath.Join(home, ".local", "bin", ompExecutableNames()[0])
os.MkdirAll(filepath.Dir(fallback), 0o755)
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
got, err := o.findPath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != fallback {
t.Errorf("findPath() = %q, want %q", got, fallback)
}
})
t.Run("falls back to ~/.bun/bin/omp", func(t *testing.T) {
home := t.TempDir()
setOMPTestHome(t, home)
t.Setenv("PATH", t.TempDir())
fallback := filepath.Join(home, ".bun", "bin", ompExecutableNames()[0])
os.MkdirAll(filepath.Dir(fallback), 0o755)
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
got, err := o.findPath()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != fallback {
t.Errorf("findPath() = %q, want %q", got, fallback)
}
})
t.Run("returns error when not found", func(t *testing.T) {
home := t.TempDir()
setOMPTestHome(t, home)
t.Setenv("PATH", t.TempDir())
if _, err := o.findPath(); err == nil {
t.Fatal("expected error, got nil")
}
})
}
func TestOMPConfigureWithModelsWritesModelsYML(t *testing.T) {
home := t.TempDir()
setOMPTestHome(t, home)
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
o := &OMP{}
models := []LaunchModel{
{
Name: "glm-5.1:cloud",
ContextLength: 202_752,
MaxOutputTokens: 131_072,
},
{
Name: "qwen3.6",
Capabilities: []modelpkg.Capability{modelpkg.CapabilityVision},
},
}
if err := o.ConfigureWithModels("glm-5.1:cloud", models); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
path := filepath.Join(home, ".omp", "agent", "models.yml")
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("failed to read models.yml: %v", err)
}
cfg := parseOMPConfigYAML(t, data)
provider := ompProviderFromYAML(t, cfg)
if provider["baseUrl"] != "http://127.0.0.1:11434/v1" {
t.Fatalf("baseUrl = %v, want connectable OpenAI-compatible host", provider["baseUrl"])
}
if provider["api"] != "openai-responses" {
t.Fatalf("api = %v, want openai-responses", provider["api"])
}
if provider["auth"] != "none" {
t.Fatalf("auth = %v, want none", provider["auth"])
}
discovery, _ := provider["discovery"].(map[string]any)
if discovery["type"] != "ollama" {
t.Fatalf("discovery = %v, want type ollama", discovery)
}
entries := ompModelEntriesFromYAML(t, provider)
if len(entries) != 2 {
t.Fatalf("models length = %d, want 2", len(entries))
}
if entries[0]["id"] != "glm-5.1:cloud" {
t.Fatalf("first model id = %v, want primary first", entries[0]["id"])
}
if got := numericYAMLValue(entries[0]["contextWindow"]); got != 202_752 {
t.Fatalf("contextWindow = %d, want 202752", got)
}
if got := numericYAMLValue(entries[0]["maxTokens"]); got != 131_072 {
t.Fatalf("maxTokens = %d, want 131072", got)
}
if input := stringSliceYAMLValue(entries[1]["input"]); !slices.Equal(input, []string{"text", "image"}) {
t.Fatalf("vision input = %v, want [text image]", input)
}
if got := o.CurrentModel(); got != "glm-5.1:cloud" {
t.Fatalf("CurrentModel = %q, want glm-5.1:cloud", got)
}
configPath := filepath.Join(home, ".omp", "agent", "config.yml")
configData, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config.yml: %v", err)
}
config := parseOMPConfigYAML(t, configData)
if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion {
t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion)
}
if paths := o.Paths(); !slices.Equal(paths, []string{path, configPath}) {
t.Fatalf("Paths = %v, want [%s %s]", paths, path, configPath)
}
}
func TestOMPConfigureWithModelsPreservesExistingConfig(t *testing.T) {
home := t.TempDir()
setOMPTestHome(t, home)
modelsPath := filepath.Join(home, ".omp", "agent", "models.yml")
if err := os.MkdirAll(filepath.Dir(modelsPath), 0o755); err != nil {
t.Fatal(err)
}
existing := []byte(`
providers:
anthropic:
baseUrl: https://example.com/anthropic
ollama:
baseUrl: http://old-host:11434
api: openai-responses
auth: none
models:
- id: old-model
name: Old Model
customField: keep-me
`)
if err := os.WriteFile(modelsPath, existing, 0o644); err != nil {
t.Fatal(err)
}
configPath := filepath.Join(home, ".omp", "agent", "config.yml")
existingConfig := []byte(`
lastChangelogVersion: 15.7.6
setupVersion: 0
theme: monochrome
`)
if err := os.WriteFile(configPath, existingConfig, 0o644); err != nil {
t.Fatal(err)
}
o := &OMP{}
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}, {Name: "old-model"}}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
data, err := os.ReadFile(modelsPath)
if err != nil {
t.Fatal(err)
}
cfg := parseOMPConfigYAML(t, data)
providers, _ := cfg["providers"].(map[string]any)
if _, ok := providers["anthropic"]; !ok {
t.Fatalf("expected non-Ollama provider to be preserved: %v", providers)
}
provider := ompProviderFromYAML(t, cfg)
if provider["baseUrl"] != "http://127.0.0.1:11434/v1" {
t.Fatalf("baseUrl = %v, want repaired OpenAI-compatible host", provider["baseUrl"])
}
entries := ompModelEntriesFromYAML(t, provider)
if len(entries) != 2 {
t.Fatalf("models length = %d, want 2", len(entries))
}
if entries[0]["id"] != "new-model" {
t.Fatalf("first model id = %v, want new-model", entries[0]["id"])
}
if entries[1]["id"] != "old-model" {
t.Fatalf("second model id = %v, want old-model", entries[1]["id"])
}
if entries[1]["customField"] != "keep-me" {
t.Fatalf("custom field was not preserved: %v", entries[1])
}
configData, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
config := parseOMPConfigYAML(t, configData)
if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion {
t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion)
}
if config["theme"] != "monochrome" {
t.Fatalf("theme was not preserved: %v", config)
}
if config["lastChangelogVersion"] != "15.7.6" {
t.Fatalf("lastChangelogVersion was not preserved: %v", config)
}
}
func TestOMPConfigureWithModelsAlwaysMarksSetupComplete(t *testing.T) {
home := t.TempDir()
setOMPTestHome(t, home)
configPath := filepath.Join(home, ".omp", "agent", "config.yml")
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, []byte("setupVersion: 2\n"), 0o644); err != nil {
t.Fatal(err)
}
o := &OMP{}
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
configData, err := os.ReadFile(configPath)
if err != nil {
t.Fatal(err)
}
config := parseOMPConfigYAML(t, configData)
if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion {
t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion)
}
}
func TestOMPConfigureWithModelsRespectsPiConfigDir(t *testing.T) {
home := t.TempDir()
setOMPTestHome(t, home)
t.Setenv("PI_CONFIG_DIR", ".custom-omp")
o := &OMP{}
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
modelsPath := filepath.Join(home, ".custom-omp", "agent", "models.yml")
configPath := filepath.Join(home, ".custom-omp", "agent", "config.yml")
for _, path := range []string{modelsPath, configPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected %s to be written: %v", path, err)
}
}
if _, err := os.Stat(filepath.Join(home, ".omp", "agent", "models.yml")); !os.IsNotExist(err) {
t.Fatalf("expected default OMP models path to be untouched, got err %v", err)
}
if paths := o.Paths(); !slices.Equal(paths, []string{modelsPath, configPath}) {
t.Fatalf("Paths = %v, want [%s %s]", paths, modelsPath, configPath)
}
}
func TestOMPConfigureWithModelsRespectsPiCodingAgentDir(t *testing.T) {
home := t.TempDir()
setOMPTestHome(t, home)
agentDir := filepath.Join(home, "agent-override")
t.Setenv("PI_CONFIG_DIR", ".ignored-omp")
t.Setenv("PI_CODING_AGENT_DIR", agentDir)
o := &OMP{}
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil {
t.Fatalf("ConfigureWithModels returned error: %v", err)
}
modelsPath := filepath.Join(agentDir, "models.yml")
configPath := filepath.Join(agentDir, "config.yml")
for _, path := range []string{modelsPath, configPath} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected %s to be written: %v", path, err)
}
}
if _, err := os.Stat(filepath.Join(home, ".ignored-omp", "agent", "models.yml")); !os.IsNotExist(err) {
t.Fatalf("expected PI_CONFIG_DIR path to be ignored when PI_CODING_AGENT_DIR is set, got err %v", err)
}
if got := o.CurrentModel(); got != "new-model" {
t.Fatalf("CurrentModel = %q, want new-model", got)
}
}
func TestOMPCurrentModelRequiresHealthyProvider(t *testing.T) {
home := t.TempDir()
setOMPTestHome(t, home)
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
modelsPath := filepath.Join(home, ".omp", "agent", "models.yml")
if err := os.MkdirAll(filepath.Dir(modelsPath), 0o755); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
provider string
}{
{
name: "wrong base url",
provider: "" +
" baseUrl: http://127.0.0.1:9999/v1\n" +
" api: openai-responses\n" +
" auth: none\n" +
" discovery:\n" +
" type: ollama\n",
},
{
name: "wrong api",
provider: "" +
" baseUrl: http://127.0.0.1:11434/v1\n" +
" api: openai-chat\n" +
" auth: none\n" +
" discovery:\n" +
" type: ollama\n",
},
{
name: "wrong auth",
provider: "" +
" baseUrl: http://127.0.0.1:11434/v1\n" +
" api: openai-responses\n" +
" auth: api-key\n" +
" discovery:\n" +
" type: ollama\n",
},
{
name: "wrong discovery",
provider: "" +
" baseUrl: http://127.0.0.1:11434/v1\n" +
" api: openai-responses\n" +
" auth: none\n" +
" discovery:\n" +
" type: static\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := "providers:\n" +
" ollama:\n" +
tt.provider +
" models:\n" +
" - id: gemma4\n"
if err := os.WriteFile(modelsPath, []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
if got := (&OMP{}).CurrentModel(); got != "" {
t.Fatalf("expected stale config to return empty current model, got %q", got)
}
})
}
}
func parseOMPConfigYAML(t *testing.T, data []byte) map[string]any {
t.Helper()
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("generated YAML did not parse: %v\n%s", err, data)
}
return cfg
}
func ompProviderFromYAML(t *testing.T, cfg map[string]any) map[string]any {
t.Helper()
providers, ok := cfg["providers"].(map[string]any)
if !ok {
t.Fatalf("providers missing from config: %v", cfg)
}
provider, ok := providers["ollama"].(map[string]any)
if !ok {
t.Fatalf("ollama provider missing from config: %v", providers)
}
return provider
}
func ompModelEntriesFromYAML(t *testing.T, provider map[string]any) []map[string]any {
t.Helper()
rawModels, ok := provider["models"].([]any)
if !ok {
t.Fatalf("provider models missing: %v", provider)
}
models := make([]map[string]any, 0, len(rawModels))
for _, raw := range rawModels {
entry, ok := raw.(map[string]any)
if !ok {
t.Fatalf("model entry has unexpected type %T: %v", raw, raw)
}
models = append(models, entry)
}
return models
}
func numericYAMLValue(value any) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
default:
return 0
}
}
func stringSliceYAMLValue(value any) []string {
raw, _ := value.([]any)
out := make([]string, 0, len(raw))
for _, item := range raw {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
+2 -4
View File
@@ -278,14 +278,12 @@ func buildModelEntries(modelList []LaunchModel) map[string]any {
"output": []string{"text"},
}
}
if model.ContextLength > 0 || model.MaxOutputTokens > 0 {
if model.MaxOutputTokens > 0 {
limit := make(map[string]any)
if model.ContextLength > 0 {
limit["context"] = model.ContextLength
}
if model.MaxOutputTokens > 0 {
limit["output"] = model.MaxOutputTokens
}
limit["output"] = model.MaxOutputTokens
entry["limit"] = limit
}
models[model.Name] = entry
+8
View File
@@ -196,6 +196,14 @@ func TestBuildModelEntries(t *testing.T) {
t.Fatalf("limit = %v, want context/output", limit)
}
})
t.Run("omits context-only limits", func(t *testing.T) {
models := buildModelEntries([]LaunchModel{{Name: "qwen2.5:0.5b", ContextLength: 32768}})
entry, _ := models["qwen2.5:0.5b"].(map[string]any)
if _, ok := entry["limit"]; ok {
t.Fatalf("limit should be omitted when output limit is unknown, got %v", entry["limit"])
}
})
}
func TestOpenCodeModels_ReturnsNil(t *testing.T) {
+370 -21
View File
@@ -4,11 +4,15 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strings"
"time"
"github.com/ollama/ollama/api"
"github.com/ollama/ollama/cmd/internal/fileutil"
@@ -19,13 +23,16 @@ import (
type Pi struct{}
const (
piNpmPackage = "@mariozechner/pi-coding-agent"
piWebSearchSource = "npm:@ollama/pi-web-search"
piWebSearchPkg = "@ollama/pi-web-search"
piNpmPackage = "@earendil-works/pi-coding-agent"
piLegacyNpmPackage = "@mariozechner/pi-coding-agent"
piWebSearchSource = "npm:@ollama/pi-web-search"
piWebSearchPkg = "@ollama/pi-web-search"
)
func (p *Pi) String() string { return "Pi" }
var npmRegistryBaseURL = "https://registry.npmjs.org"
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 {
@@ -58,6 +65,22 @@ func ensureNpmInstalled() error {
func ensurePiInstalled() (string, error) {
if _, err := exec.LookPath("pi"); err == nil {
install, pkgErr := installedPiPackageInfo()
if pkgErr != nil {
fmt.Fprintf(os.Stderr, "%sCould not verify which Pi package is installed: %v%s\n", ansiYellow, pkgErr, ansiReset)
fmt.Fprintf(os.Stderr, "Pi will still launch. To switch to the official package manually:\n npm uninstall -g %s\n npm install -g %s\n\n", piLegacyNpmPackage, piNpmPackage)
return "pi", nil
}
if install.packageName == piLegacyNpmPackage {
fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset)
if err := migrateLegacyPiPackage(install.npmPrefix); err != nil {
return "", err
}
if err := requirePiOnPath(); err != nil {
return "", err
}
}
return "pi", nil
}
@@ -65,7 +88,29 @@ func ensurePiInstalled() (string, error) {
return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch pi")
}
ok, err := ConfirmPrompt("Pi is not installed. Install with npm?")
install, pkgErr := installedPiPackageInfo()
if pkgErr == nil && install.packageName == piLegacyNpmPackage {
fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset)
if err := migrateLegacyPiPackage(install.npmPrefix); err != nil {
return "", err
}
if err := requirePiOnPath(); err != nil {
return "", err
}
return "pi", nil
}
if pkgErr == nil && install.packageName == piNpmPackage {
fmt.Fprintf(os.Stderr, "%sInstalling Pi...%s\n", ansiGray, ansiReset)
if err := installPiPackageWithPrefix(install.npmPrefix); err != nil {
return "", err
}
if err := requirePiOnPath(); err != nil {
return "", err
}
return "pi", nil
}
ok, err := ConfirmPrompt("Install Pi with npm?")
if err != nil {
return "", err
}
@@ -74,36 +119,252 @@ func ensurePiInstalled() (string, error) {
}
fmt.Fprintf(os.Stderr, "\nInstalling Pi...\n")
cmd := exec.Command("npm", "install", "-g", piNpmPackage+"@latest")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to install pi: %w", err)
if err := installPiPackage(); err != nil {
return "", err
}
if _, err := exec.LookPath("pi"); err != nil {
return "", fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
if err := requirePiOnPath(); err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "%sPi installed successfully%s\n\n", ansiGreen, ansiReset)
return "pi", nil
}
func requirePiOnPath() error {
if _, err := exec.LookPath("pi"); err != nil {
return fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
}
return nil
}
func installPiPackage() error {
return installPiPackageWithPrefix("")
}
func installPiPackageWithPrefix(prefix string) error {
if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest")...); err != nil {
return fmt.Errorf("failed to install pi: %w", err)
}
return nil
}
func migrateLegacyPiPackage(prefix string) error {
if err := installPiPackageForced(prefix); err != nil {
return err
}
installed, err := npmPackageInstalledWithPrefix(piNpmPackage, prefix)
if err != nil {
return fmt.Errorf("failed to verify official pi package: %w", err)
}
if !installed {
return fmt.Errorf("failed to verify official pi package")
}
if err := uninstallLegacyPiPackageWithPrefix(prefix); err != nil {
return err
}
return installPiPackageWithPrefix(prefix)
}
func installPiPackageForced(prefix string) error {
if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest", "--force")...); err != nil {
return fmt.Errorf("failed to install pi: %w", err)
}
return nil
}
func uninstallLegacyPiPackageWithPrefix(prefix string) error {
if err := runQuietCommand("npm", npmArgs(prefix, "uninstall", "-g", piLegacyNpmPackage)...); err != nil {
return fmt.Errorf("failed to remove legacy pi package: %w", err)
}
return nil
}
func runQuietCommand(name string, args ...string) error {
cmd := exec.Command(name, args...)
out, err := cmd.CombinedOutput()
if err == nil {
return nil
}
msg := strings.TrimSpace(string(out))
if msg == "" {
return err
}
return fmt.Errorf("%w: %s", err, msg)
}
type piPackageInstall struct {
packageName string
npmPrefix string
}
func installedPiPackageInfo() (piPackageInstall, error) {
if _, err := exec.LookPath("npm"); err != nil {
return piPackageInstall{}, err
}
if bin, err := exec.LookPath("pi"); err == nil {
install, err := piPackageInstallFromBinary(bin)
if err == nil && install.packageName != "" {
return install, nil
}
}
installed, err := npmPackageInstalled(piLegacyNpmPackage)
if err != nil {
return piPackageInstall{}, err
}
if installed {
return piPackageInstall{packageName: piLegacyNpmPackage}, nil
}
installed, err = npmPackageInstalled(piNpmPackage)
if err != nil {
return piPackageInstall{}, err
}
if installed {
return piPackageInstall{packageName: piNpmPackage}, nil
}
return piPackageInstall{}, nil
}
func piPackageInstallFromBinary(bin string) (piPackageInstall, error) {
realPath, err := filepath.EvalSymlinks(bin)
if err != nil {
realPath = bin
}
dir := filepath.Dir(realPath)
for {
packageJSON := filepath.Join(dir, "package.json")
data, err := os.ReadFile(packageJSON)
if err == nil {
var payload struct {
Name string `json:"name"`
}
if json.Unmarshal(data, &payload) == nil && (payload.Name == piLegacyNpmPackage || payload.Name == piNpmPackage) {
return piPackageInstall{packageName: payload.Name, npmPrefix: npmPrefixForPackageRoot(dir)}, nil
}
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return piPackageInstall{}, nil
}
func npmPrefixForPackageRoot(packageRoot string) string {
return npmPrefixForPackageRootForGOOS(filepath.Clean(packageRoot), runtime.GOOS, string(filepath.Separator))
}
func npmPrefixForPackageRootForGOOS(packageRoot, goos, separator string) string {
packageRoot = strings.TrimRight(packageRoot, separator)
nodeModules := separator + "node_modules" + separator
idx := strings.LastIndex(packageRoot, nodeModules)
if idx == -1 {
return ""
}
rootDir := packageRoot[:idx]
if pathBaseForSeparator(rootDir, separator) == "lib" {
// Unix npm global root is <prefix>/lib/node_modules.
return pathDirForSeparator(rootDir, separator)
}
if goos == "windows" {
// Windows npm global root is usually <prefix>\node_modules.
return rootDir
}
return ""
}
func pathBaseForSeparator(path, separator string) string {
path = strings.TrimRight(path, separator)
idx := strings.LastIndex(path, separator)
if idx == -1 {
return path
}
return path[idx+len(separator):]
}
func pathDirForSeparator(path, separator string) string {
path = strings.TrimRight(path, separator)
idx := strings.LastIndex(path, separator)
if idx == -1 {
return ""
}
if idx == 0 {
return separator
}
return path[:idx]
}
func npmPackageInstalled(pkg string) (bool, error) {
return npmPackageInstalledWithPrefix(pkg, "")
}
func npmPackageInstalledWithPrefix(pkg, prefix string) (bool, error) {
cmd := exec.Command("npm", npmArgs(prefix, "ls", "-g", pkg, "--depth=0", "--json")...)
out, err := cmd.Output()
var payload struct {
Dependencies map[string]json.RawMessage `json:"dependencies"`
}
if parseErr := json.Unmarshal(out, &payload); parseErr == nil {
_, ok := payload.Dependencies[pkg]
if ok {
return true, nil
}
return false, nil
}
if err == nil {
return false, nil
}
if exitErr, ok := err.(*exec.ExitError); ok {
msg := strings.TrimSpace(string(exitErr.Stderr))
if msg == "" {
msg = strings.TrimSpace(string(out))
}
if msg == "" {
return false, err
}
return false, fmt.Errorf("%w: %s", err, msg)
}
return false, err
}
func npmArgs(prefix string, args ...string) []string {
if prefix == "" {
return args
}
return append([]string{"--prefix", prefix}, args...)
}
func ensurePiWebSearchPackage(bin string) {
if !shouldManagePiWebSearch() {
if !shouldManageOllamaWebSearch() {
fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset)
return
}
fmt.Fprintf(os.Stderr, "%sChecking Pi web search package...%s\n", ansiGray, ansiReset)
installed, err := piPackageInstalled(bin, piWebSearchSource)
pkg, err := piPackageInfo(bin, piWebSearchSource)
if err != nil {
fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
return
}
if !installed {
if !pkg.installed {
fmt.Fprintf(os.Stderr, "%sInstalling %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
cmd := exec.Command(bin, "install", piWebSearchSource)
cmd.Stdout = os.Stdout
@@ -117,6 +378,11 @@ func ensurePiWebSearchPackage(bin string) {
return
}
updateAvailable, err := piWebSearchUpdateAvailable(pkg.installedPath)
if err != nil || !updateAvailable {
return
}
fmt.Fprintf(os.Stderr, "%sUpdating %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
cmd := exec.Command(bin, "update", piWebSearchSource)
cmd.Stdout = os.Stdout
@@ -129,7 +395,7 @@ func ensurePiWebSearchPackage(bin string) {
fmt.Fprintf(os.Stderr, "%s ✓ Updated %s%s\n", ansiGreen, piWebSearchPkg, ansiReset)
}
func shouldManagePiWebSearch() bool {
func shouldManageOllamaWebSearch() bool {
client, err := api.ClientFromEnvironment()
if err != nil {
return true
@@ -142,25 +408,108 @@ func shouldManagePiWebSearch() bool {
return true
}
func piPackageInstalled(bin, source string) (bool, error) {
type piPackageListEntry struct {
installed bool
installedPath string
}
func piPackageInfo(bin, source string) (piPackageListEntry, error) {
cmd := exec.Command(bin, "list")
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg == "" {
return false, err
return piPackageListEntry{}, err
}
return false, fmt.Errorf("%w: %s", err, msg)
return piPackageListEntry{}, fmt.Errorf("%w: %s", err, msg)
}
for _, line := range strings.Split(string(out), "\n") {
lines := strings.Split(string(out), "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, source) {
return true, nil
return piPackageListEntry{installed: true, installedPath: piPackageListInstalledPath(lines[i+1:])}, nil
}
}
return false, nil
return piPackageListEntry{}, nil
}
func piPackageListInstalledPath(lines []string) string {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if strings.HasPrefix(trimmed, "npm:") || strings.HasPrefix(trimmed, "git:") || strings.HasSuffix(trimmed, ":") {
return ""
}
if filepath.IsAbs(trimmed) {
return trimmed
}
return ""
}
return ""
}
func piWebSearchUpdateAvailable(installedPath string) (bool, error) {
if piOfflineModeEnabled() || installedPath == "" {
return false, nil
}
installedVersion, err := npmInstalledPackageVersion(installedPath)
if err != nil || installedVersion == "" {
return false, err
}
latestVersion, err := npmLatestPackageVersion(piWebSearchPkg)
if err != nil || latestVersion == "" {
return false, err
}
return latestVersion != installedVersion, nil
}
func piOfflineModeEnabled() bool {
value := os.Getenv("PI_OFFLINE")
return value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "yes")
}
func npmInstalledPackageVersion(installedPath string) (string, error) {
data, err := os.ReadFile(filepath.Join(installedPath, "package.json"))
if err != nil {
return "", err
}
var payload struct {
Version string `json:"version"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return "", err
}
return payload.Version, nil
}
func npmLatestPackageVersion(pkg string) (string, error) {
client := http.Client{Timeout: 10 * time.Second}
requestURL := strings.TrimRight(npmRegistryBaseURL, "/") + "/" + url.PathEscape(pkg) + "/latest"
resp, err := client.Get(requestURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("npm registry returned %s", resp.Status)
}
var payload struct {
Version string `json:"version"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", err
}
return payload.Version, nil
}
func (p *Pi) Paths() []string {
+522 -11
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
@@ -33,6 +34,89 @@ func TestPiIntegration(t *testing.T) {
})
}
func TestPiInstallSpec_UsesOfficialPackage(t *testing.T) {
spec, err := LookupIntegrationSpec("pi")
if err != nil {
t.Fatalf("LookupIntegrationSpec(pi) error = %v", err)
}
want := []string{"npm", "install", "-g", piNpmPackage + "@latest"}
if got := spec.Install.Command; !slices.Equal(got, want) {
t.Fatalf("pi install command = %v, want %v", got, want)
}
}
func TestPiNpmPrefixForPackageRoot(t *testing.T) {
prefix := filepath.Join(t.TempDir(), "npm-global")
t.Run("unix npm global layout", func(t *testing.T) {
packageRoot := filepath.Join(prefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent")
if got := npmPrefixForPackageRoot(packageRoot); got != prefix {
t.Fatalf("npmPrefixForPackageRoot() = %q, want %q", got, prefix)
}
})
tests := []struct {
name string
goos string
separator string
packageRoot string
want string
}{
{
name: "macos npm global layout",
goos: "darwin",
separator: "/",
packageRoot: "/Users/parth/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent",
want: "/Users/parth/.npm-global",
},
{
name: "linux npm global layout",
goos: "linux",
separator: "/",
packageRoot: "/home/parth/.npm-global/lib/node_modules/@mariozechner/pi-coding-agent",
want: "/home/parth/.npm-global",
},
{
name: "windows npm global layout",
goos: "windows",
separator: `\`,
packageRoot: `C:\Users\parth\AppData\Roaming\npm\node_modules\@mariozechner\pi-coding-agent`,
want: `C:\Users\parth\AppData\Roaming\npm`,
},
{
name: "windows lib npm global layout",
goos: "windows",
separator: `\`,
packageRoot: `C:\Users\parth\.npm-global\lib\node_modules\@mariozechner\pi-coding-agent`,
want: `C:\Users\parth\.npm-global`,
},
{
name: "non-windows direct node_modules layout",
goos: "linux",
separator: "/",
packageRoot: "/home/parth/.npm-global/node_modules/@mariozechner/pi-coding-agent",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := npmPrefixForPackageRootForGOOS(tt.packageRoot, tt.goos, tt.separator)
if got != tt.want {
t.Fatalf("npmPrefixForPackageRootForGOOS() = %q, want %q", got, tt.want)
}
})
}
t.Run("host windows npm global layout", func(t *testing.T) {
packageRoot := filepath.Join(prefix, "node_modules", "@mariozechner", "pi-coding-agent")
want := ""
if runtime.GOOS == "windows" {
want = prefix
}
if got := npmPrefixForPackageRoot(packageRoot); got != want {
t.Fatalf("npmPrefixForPackageRoot() = %q, want %q", got, want)
}
})
}
func TestPiRun_InstallAndWebSearchLifecycle(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell test binaries")
@@ -75,6 +159,99 @@ exit 0
writeScript(t, filepath.Join(dir, "npm"), "#!/bin/sh\nexit 0\n")
}
seedLegacyPiNpm := func(t *testing.T, dir string) {
t.Helper()
npmPath := filepath.Join(dir, "npm")
npmScript := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
if [ "$PI_FAIL_OFFICIAL_INSTALL" = "1" ]; then
echo "install failed" >&2
exit 1
fi
: > %q
exit 0
fi
if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
: > %q
exit 0
fi
if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then
if [ "$3" = %q ]; then
if [ -f %q ]; then
printf '{"name":"lib","dependencies":{"%s":{"version":"0.58.0","overridden":false}}}\n'
exit 0
fi
printf '{"name":"lib"}\n'
exit 1
fi
if [ "$3" = %q ]; then
if [ ! -f %q ]; then
printf '{"name":"lib","dependencies":{"%s":{"version":"0.57.1","overridden":false}}}\n'
exit 0
fi
printf '{"name":"lib"}\n'
exit 1
fi
fi
exit 0
`, filepath.Join(dir, "npm.log"), piNpmPackage+"@latest", filepath.Join(dir, "official-installed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piNpmPackage, filepath.Join(dir, "official-installed"), piNpmPackage, piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage)
writeScript(t, npmPath, npmScript)
}
seedBothPiPackagesNpm := func(t *testing.T, dir string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "official-installed"), nil, 0o644); err != nil {
t.Fatal(err)
}
npmPath := filepath.Join(dir, "npm")
npmScript := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
: > %q
exit 0
fi
if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
: > %q
exit 0
fi
if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then
if [ "$3" = %q ]; then
if [ ! -f %q ]; then
printf '{"name":"lib","dependencies":{"%s":{"version":"0.57.1","overridden":false}}}\n'
exit 0
fi
printf '{"name":"lib"}\n'
exit 1
fi
if [ "$3" = %q ]; then
if [ -f %q ]; then
printf '{"name":"lib","dependencies":{"%s":{"version":"0.58.0","overridden":false}}}\n'
exit 0
fi
printf '{"name":"lib"}\n'
exit 1
fi
fi
exit 0
`, filepath.Join(dir, "npm.log"), piNpmPackage+"@latest", filepath.Join(dir, "official-installed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage, filepath.Join(dir, "legacy-removed"), piLegacyNpmPackage, piNpmPackage, filepath.Join(dir, "official-installed"), piNpmPackage)
writeScript(t, npmPath, npmScript)
}
seedBrokenPiProbeNpm := func(t *testing.T, dir string) {
t.Helper()
npmPath := filepath.Join(dir, "npm")
npmScript := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then
echo "npm probe failed" >&2
exit 1
fi
exit 0
`, filepath.Join(dir, "npm.log"))
writeScript(t, npmPath, npmScript)
}
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
t.Helper()
oldConfirm := DefaultConfirmPrompt
@@ -97,13 +274,46 @@ exit 0
t.Setenv("OLLAMA_HOST", srv.URL)
}
setNpmRegistryVersion := func(t *testing.T, version string) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/latest") {
fmt.Fprintf(w, `{"version":%q}`, version)
return
}
http.NotFound(w, r)
}))
oldRegistry := npmRegistryBaseURL
npmRegistryBaseURL = srv.URL
t.Cleanup(func() {
npmRegistryBaseURL = oldRegistry
srv.Close()
})
}
seedPiWebSearchPackage := func(t *testing.T, dir, version string) {
t.Helper()
packagePath := filepath.Join(dir, ".npm-global", "lib", "node_modules", "@ollama", "pi-web-search")
if err := os.MkdirAll(packagePath, 0o755); err != nil {
t.Fatal(err)
}
packageJSON := fmt.Sprintf(`{"name":%q,"version":%q}`, piWebSearchPkg, version)
if err := os.WriteFile(filepath.Join(packagePath, "package.json"), []byte(packageJSON), 0o644); err != nil {
t.Fatal(err)
}
list := fmt.Sprintf("User packages:\n %s\n %s\n", piWebSearchSource, packagePath)
if err := os.WriteFile(filepath.Join(dir, "pi-list.txt"), []byte(list), 0o644); err != nil {
t.Fatal(err)
}
}
t.Run("pi missing + user accepts install", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
setCloudStatus(t, false)
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n npm:@ollama/pi-web-search\n"), 0o644); err != nil {
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n"), 0o644); err != nil {
t.Fatal(err)
}
@@ -128,7 +338,7 @@ exit 0
writeScript(t, filepath.Join(tmpDir, "npm"), npmScript)
withConfirm(t, func(prompt string) (bool, error) {
if strings.Contains(prompt, "Pi is not installed.") {
if strings.Contains(prompt, "Install Pi with npm?") {
return true, nil
}
return true, nil
@@ -136,7 +346,8 @@ exit 0
p := &Pi{}
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
t.Fatalf("Run() error = %v", err)
npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls)
}
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
@@ -155,8 +366,8 @@ exit 0
if !strings.Contains(got, "list\n") {
t.Fatalf("expected pi list call, got:\n%s", got)
}
if !strings.Contains(got, "update "+piWebSearchSource+"\n") {
t.Fatalf("expected pi update call, got:\n%s", got)
if !strings.Contains(got, "install "+piWebSearchSource+"\n") {
t.Fatalf("expected pi web search install call, got:\n%s", got)
}
if !strings.Contains(got, "--version\n") {
t.Fatalf("expected final pi launch call, got:\n%s", got)
@@ -171,7 +382,7 @@ exit 0
writeScript(t, filepath.Join(tmpDir, "npm"), "#!/bin/sh\nexit 0\n")
withConfirm(t, func(prompt string) (bool, error) {
if strings.Contains(prompt, "Pi is not installed.") {
if strings.Contains(prompt, "Install Pi with npm?") {
return false, nil
}
return true, nil
@@ -184,6 +395,277 @@ exit 0
}
})
t.Run("legacy pi package migrates automatically to official package", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
setCloudStatus(t, false)
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
setNpmRegistryVersion(t, "1.0.0")
seedPiScript(t, tmpDir)
seedLegacyPiNpm(t, tmpDir)
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
return false, nil
})
p := &Pi{}
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls)
}
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
if err != nil {
t.Fatal(err)
}
gotNPM := string(npmCalls)
if !strings.Contains(gotNPM, "ls -g "+piLegacyNpmPackage+" --depth=0 --json\n") {
t.Fatalf("expected legacy npm probe, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") {
t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "ls -g "+piNpmPackage+" --depth=0 --json\n") {
t.Fatalf("expected official npm verification probe, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") {
t.Fatalf("expected legacy npm uninstall call, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest\n") {
t.Fatalf("expected official npm install call, got:\n%s", gotNPM)
}
if strings.Index(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") > strings.Index(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") {
t.Fatalf("expected official install before legacy uninstall, got:\n%s", gotNPM)
}
if strings.Index(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") > strings.LastIndex(gotNPM, "install -g "+piNpmPackage+"@latest\n") {
t.Fatalf("expected official repair install after legacy uninstall, got:\n%s", gotNPM)
}
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
if err != nil {
t.Fatal(err)
}
gotPi := string(piCalls)
if strings.Contains(gotPi, "update "+piWebSearchSource+"\n") {
t.Fatalf("did not expect pi update call when web search is current, got:\n%s", gotPi)
}
if !strings.Contains(gotPi, "--version\n") {
t.Fatalf("expected final pi launch call, got:\n%s", gotPi)
}
})
t.Run("legacy pi package migrates even when official package is also installed", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
setCloudStatus(t, false)
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
setNpmRegistryVersion(t, "1.0.0")
seedPiScript(t, tmpDir)
seedBothPiPackagesNpm(t, tmpDir)
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
return false, nil
})
p := &Pi{}
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls)
}
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
if err != nil {
t.Fatal(err)
}
gotNPM := string(npmCalls)
if !strings.Contains(gotNPM, "ls -g "+piLegacyNpmPackage+" --depth=0 --json\n") {
t.Fatalf("expected legacy npm probe, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") {
t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "ls -g "+piNpmPackage+" --depth=0 --json\n") {
t.Fatalf("expected official npm verification probe, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") {
t.Fatalf("expected legacy npm uninstall call, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest\n") {
t.Fatalf("expected official npm install call, got:\n%s", gotNPM)
}
})
t.Run("legacy pi package outside current npm prefix migrates with binary prefix", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
setCloudStatus(t, false)
commandDir := filepath.Join(tmpDir, "commands")
prefix := filepath.Join(tmpDir, "npm-global")
legacyRoot := filepath.Join(prefix, "lib", "node_modules", "@mariozechner", "pi-coding-agent")
legacyDist := filepath.Join(legacyRoot, "dist")
if err := os.MkdirAll(filepath.Join(prefix, "bin"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(legacyDist, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyRoot, "package.json"), []byte(`{"name":"`+piLegacyNpmPackage+`","version":"0.67.1"}`), 0o644); err != nil {
t.Fatal(err)
}
writeScript(t, filepath.Join(legacyDist, "cli.js"), fmt.Sprintf("#!/bin/sh\necho \"$@\" >> %q\nexit 0\n", filepath.Join(tmpDir, "pi.log")))
if err := os.Symlink(filepath.Join(legacyDist, "cli.js"), filepath.Join(prefix, "bin", "pi")); err != nil {
t.Fatal(err)
}
npmScript := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
prefix=""
if [ "$1" = "--prefix" ]; then
prefix="$2"
shift 2
fi
if [ "$1" = "install" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
: > %q
mkdir -p "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist" "$prefix/bin"
printf '{"name":"%s","version":"0.75.3"}\n' > "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/package.json"
printf '#!/bin/sh\necho "$@" >> %s\nexit 0\n' > "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
chmod +x "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js"
ln -sf "$prefix/lib/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" "$prefix/bin/pi"
exit 0
fi
if [ "$1" = "uninstall" ] && [ "$2" = "-g" ] && [ "$3" = %q ]; then
: > %q
exit 0
fi
if [ "$1" = "ls" ] && [ "$2" = "-g" ] && [ "$4" = "--depth=0" ] && [ "$5" = "--json" ]; then
if [ "$3" = %q ] && [ -f %q ]; then
printf '{"name":"lib","dependencies":{"%s":{"version":"0.75.3","overridden":false}}}\n'
exit 0
fi
if [ "$3" = %q ] && [ ! -f %q ]; then
printf '{"name":"lib","dependencies":{"%s":{"version":"0.67.1","overridden":false}}}\n'
exit 0
fi
printf '{"name":"lib"}\n'
exit 1
fi
exit 0
`, filepath.Join(tmpDir, "npm.log"), piNpmPackage+"@latest", filepath.Join(tmpDir, "official-installed"), piNpmPackage, filepath.Join(tmpDir, "pi.log"), piLegacyNpmPackage, filepath.Join(tmpDir, "legacy-removed"), piNpmPackage, filepath.Join(tmpDir, "official-installed"), piNpmPackage, piLegacyNpmPackage, filepath.Join(tmpDir, "legacy-removed"), piLegacyNpmPackage)
if err := os.MkdirAll(commandDir, 0o755); err != nil {
t.Fatal(err)
}
writeScript(t, filepath.Join(commandDir, "npm"), npmScript)
t.Setenv("PATH", commandDir+string(os.PathListSeparator)+filepath.Join(prefix, "bin"))
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
return false, nil
})
p := &Pi{}
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
npmCalls, _ := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
t.Fatalf("Run() error = %v\nnpm calls:\n%s", err, npmCalls)
}
npmCalls, err := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
if err != nil {
t.Fatal(err)
}
gotNPM := string(npmCalls)
resolvedPrefix, err := filepath.EvalSymlinks(prefix)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" install -g "+piNpmPackage+"@latest --force\n") {
t.Fatalf("expected forced official install in pi binary prefix, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" uninstall -g "+piLegacyNpmPackage+"\n") {
t.Fatalf("expected legacy uninstall in pi binary prefix, got:\n%s", gotNPM)
}
if !strings.Contains(gotNPM, "--prefix "+resolvedPrefix+" install -g "+piNpmPackage+"@latest\n") {
t.Fatalf("expected official repair install in pi binary prefix, got:\n%s", gotNPM)
}
})
t.Run("legacy pi migration install failure does not remove legacy package", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
setCloudStatus(t, false)
t.Setenv("PI_FAIL_OFFICIAL_INSTALL", "1")
seedPiScript(t, tmpDir)
seedLegacyPiNpm(t, tmpDir)
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
return false, nil
})
p := &Pi{}
err := p.Run("ignored", nil, nil)
if err == nil || !strings.Contains(err.Error(), "failed to install pi") {
t.Fatalf("expected install failure error, got %v", err)
}
npmCalls, readErr := os.ReadFile(filepath.Join(tmpDir, "npm.log"))
if readErr != nil {
t.Fatal(readErr)
}
gotNPM := string(npmCalls)
if !strings.Contains(gotNPM, "install -g "+piNpmPackage+"@latest --force\n") {
t.Fatalf("expected forced official npm install call, got:\n%s", gotNPM)
}
if strings.Contains(gotNPM, "uninstall -g "+piLegacyNpmPackage+"\n") {
t.Fatalf("did not expect legacy uninstall after official install failure, got:\n%s", gotNPM)
}
})
t.Run("pi installed + package probe failure warns and still launches", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
setCloudStatus(t, false)
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
setNpmRegistryVersion(t, "1.0.0")
seedPiScript(t, tmpDir)
seedBrokenPiProbeNpm(t, tmpDir)
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect confirmation prompt, got %q", prompt)
return false, nil
})
p := &Pi{}
stderr := captureStderr(t, func() {
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
})
if !strings.Contains(stderr, "Could not verify which Pi package is installed") {
t.Fatalf("expected package probe warning, got:\n%s", stderr)
}
if !strings.Contains(stderr, "npm uninstall -g "+piLegacyNpmPackage) {
t.Fatalf("expected manual migration steps in warning, got:\n%s", stderr)
}
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
if err != nil {
t.Fatal(err)
}
gotPi := string(piCalls)
if strings.Contains(gotPi, "update "+piWebSearchSource+"\n") {
t.Fatalf("did not expect pi update call when web search is current, got:\n%s", gotPi)
}
if !strings.Contains(gotPi, "--version\n") {
t.Fatalf("expected final pi launch call, got:\n%s", gotPi)
}
})
t.Run("pi installed + web search missing auto-installs", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
@@ -223,14 +705,41 @@ exit 0
}
})
t.Run("pi installed + web search present updates every launch", func(t *testing.T) {
t.Run("pi installed + web search present skips update when current", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
setCloudStatus(t, false)
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil {
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
setNpmRegistryVersion(t, "1.0.0")
seedPiScript(t, tmpDir)
seedNpmNoop(t, tmpDir)
p := &Pi{}
if err := p.Run("ignored", nil, []string{"doctor"}); err != nil {
t.Fatalf("Run() error = %v", err)
}
piCalls, err := os.ReadFile(filepath.Join(tmpDir, "pi.log"))
if err != nil {
t.Fatal(err)
}
got := string(piCalls)
if strings.Contains(got, "update "+piWebSearchSource+"\n") {
t.Fatalf("did not expect pi update call, got:\n%s", got)
}
if !strings.Contains(got, "doctor\n") {
t.Fatalf("expected final pi launch call, got:\n%s", got)
}
})
t.Run("pi installed + web search present updates when newer package exists", func(t *testing.T) {
tmpDir := t.TempDir()
setTestHome(t, tmpDir)
t.Setenv("PATH", tmpDir)
setCloudStatus(t, false)
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
setNpmRegistryVersion(t, "1.0.1")
seedPiScript(t, tmpDir)
seedNpmNoop(t, tmpDir)
@@ -247,6 +756,9 @@ exit 0
if !strings.Contains(got, "update "+piWebSearchSource+"\n") {
t.Fatalf("expected pi update call, got:\n%s", got)
}
if !strings.Contains(got, "doctor\n") {
t.Fatalf("expected final pi launch call, got:\n%s", got)
}
})
t.Run("web search update failure warns and continues", func(t *testing.T) {
@@ -255,9 +767,8 @@ exit 0
t.Setenv("PATH", tmpDir)
setCloudStatus(t, false)
t.Setenv("PI_FAIL_UPDATE", "1")
if err := os.WriteFile(filepath.Join(tmpDir, "pi-list.txt"), []byte("User packages:\n "+piWebSearchSource+"\n"), 0o644); err != nil {
t.Fatal(err)
}
seedPiWebSearchPackage(t, tmpDir, "1.0.0")
setNpmRegistryVersion(t, "1.0.1")
seedPiScript(t, tmpDir)
seedNpmNoop(t, tmpDir)
+461
View File
@@ -0,0 +1,461 @@
package launch
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
"github.com/ollama/ollama/envconfig"
)
const qwenOllamaEnvKey = "OLLAMA_API_KEY"
var qwenGOOS = runtime.GOOS
type Qwen struct{}
func (q *Qwen) String() string { return "Qwen Code" }
func (q *Qwen) findPath() (string, error) {
if p, err := exec.LookPath("qwen"); err == nil {
return p, nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
var candidates []string
switch qwenGOOS {
case "darwin":
candidates = []string{
"/opt/homebrew/bin/qwen",
"/usr/local/bin/qwen",
filepath.Join(home, ".npm-global", "bin", "qwen"),
filepath.Join(home, ".local", "bin", "qwen"),
filepath.Join(home, "Library", "Application Support", "qwen", "bin", "qwen"),
}
candidates = append(candidates, qwenNVMCandidatePaths(home)...)
case "windows":
candidates = []string{
filepath.Join(qwenWindowsAppData(home), "npm", "qwen.cmd"),
filepath.Join(qwenWindowsAppData(home), "npm", "qwen.exe"),
filepath.Join(qwenWindowsLocalAppData(home), "npm", "qwen.cmd"),
filepath.Join(qwenWindowsLocalAppData(home), "npm", "qwen.exe"),
filepath.Join(home, "AppData", "Local", "Programs", "qwen", "qwen.exe"),
filepath.Join(home, "AppData", "Roaming", "qwen", "bin", "qwen.exe"),
}
default:
candidates = []string{
filepath.Join(home, ".npm-global", "bin", "qwen"),
filepath.Join(home, ".local", "bin", "qwen"),
filepath.Join(home, ".cargo", "bin", "qwen"),
"/usr/local/bin/qwen",
}
candidates = append(candidates, qwenNVMCandidatePaths(home)...)
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", fmt.Errorf("qwen binary not found (checked PATH and common npm install locations)")
}
func qwenNVMCandidatePaths(home string) []string {
matches, err := filepath.Glob(filepath.Join(home, ".nvm", "versions", "node", "*", "bin", "qwen"))
if err != nil {
return nil
}
return matches
}
func qwenWindowsAppData(home string) string {
if appData := os.Getenv("APPDATA"); appData != "" {
return appData
}
return filepath.Join(home, "AppData", "Roaming")
}
func qwenWindowsLocalAppData(home string) string {
if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" {
return localAppData
}
return filepath.Join(home, "AppData", "Local")
}
func ensureQwenInstalled() (string, error) {
if path, err := (&Qwen{}).findPath(); err == nil {
return path, nil
}
if err := checkQwenInstallerDependencies(); err != nil {
return "", err
}
ok, err := ConfirmPrompt("Qwen Code is not installed. Install now?")
if err != nil {
return "", err
}
if !ok {
return "", fmt.Errorf("qwen installation cancelled")
}
bin, args, err := qwenInstallerCommand(qwenGOOS)
if err != nil {
return "", err
}
fmt.Fprintf(os.Stderr, "\nInstalling Qwen Code...\n")
shimDir, cleanup, err := qwenInstallShimDir()
if err != nil {
return "", err
}
defer cleanup()
cmd := exec.Command(bin, args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = qwenInstallerEnv(os.Environ(), shimDir)
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("failed to install qwen: %w", err)
}
path, err := (&Qwen{}).findPath()
if err != nil {
return "", fmt.Errorf("qwen was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
}
fmt.Fprintf(os.Stderr, "%sQwen Code installed successfully%s\n\n", ansiGreen, ansiReset)
return path, nil
}
func qwenInstallShimDir() (string, func(), error) {
dir, err := os.MkdirTemp("", "ollama-qwen-install-*")
if err != nil {
return "", nil, err
}
cleanup := func() {
_ = os.RemoveAll(dir)
}
if qwenGOOS == "windows" {
for _, name := range []string{"qwen.cmd", "qwen.bat"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte("@echo off\r\nexit /b 0\r\n"), 0o755); err != nil {
cleanup()
return "", nil, err
}
}
return dir, cleanup, nil
}
if err := os.WriteFile(filepath.Join(dir, "qwen"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
cleanup()
return "", nil, err
}
return dir, cleanup, nil
}
func qwenInstallerEnv(env []string, shimDir string) []string {
out := make([]string, 0, len(env)+1)
pathEntry := "PATH=" + shimDir
for _, entry := range env {
key, value, ok := strings.Cut(entry, "=")
if ok && strings.EqualFold(key, "PATH") {
pathEntry = key + "=" + shimDir + string(os.PathListSeparator) + value
continue
}
out = append(out, entry)
}
return append(out, pathEntry)
}
func checkQwenInstallerDependencies() error {
switch qwenGOOS {
case "windows":
if _, err := exec.LookPath("powershell"); err != nil {
return fmt.Errorf("qwen is not installed and required dependencies are missing\n\nInstall the following first:\n PowerShell: https://learn.microsoft.com/powershell/\n\nThen re-run:\n ollama launch qwen")
}
default:
var missing []string
if _, err := exec.LookPath("curl"); err != nil {
missing = append(missing, "curl: https://curl.se/")
}
if _, err := exec.LookPath("bash"); err != nil {
missing = append(missing, "bash: https://www.gnu.org/software/bash/")
}
if len(missing) > 0 {
return fmt.Errorf("qwen is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch qwen", strings.Join(missing, "\n "))
}
}
return nil
}
func qwenInstallerCommand(goos string) (string, []string, error) {
switch goos {
case "windows":
return "powershell", []string{
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
"$installer = Join-Path $env:TEMP 'install-qwen.bat'; Invoke-WebRequest -UseBasicParsing -Uri 'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.bat' -OutFile $installer; $content = Get-Content -Raw -Path $installer; $content = $content -replace '(?m)^\\s*call qwen\\s*$', 'REM call qwen'; Set-Content -Path $installer -Value $content -Encoding ASCII; & $installer",
}, nil
case "darwin", "linux":
return "bash", []string{
"-c",
"set -o pipefail; curl -fsSL https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/installation/install-qwen.sh | sed '/log_info \"Starting Qwen Code...\"/,/exec qwen/d' | bash",
}, nil
default:
return "", nil, fmt.Errorf("unsupported platform for qwen install: %s", goos)
}
}
func (q *Qwen) Run(model string, _ []LaunchModel, args []string) error {
qwenPath, err := q.findPath()
if err != nil {
return fmt.Errorf("qwen is not installed: %w", err)
}
cmd := exec.Command(qwenPath, qwenLaunchArgs(model, args)...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = qwenLaunchEnv(model)
return cmd.Run()
}
func (q *Qwen) Paths() []string {
path, err := q.configPath()
if err != nil {
return nil
}
return []string{path}
}
func (q *Qwen) Configure(model string) error {
if model == "" {
return nil
}
configPath, err := q.configPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
return err
}
cfg, err := q.readConfig()
if err != nil {
return err
}
applyQwenOllamaConfig(cfg, model)
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return err
}
return fileutil.WriteWithBackup(configPath, data, "qwen")
}
func applyQwenOllamaConfig(cfg map[string]any, model string) {
envCfg := qwenMap(cfg["env"])
envCfg[qwenOllamaEnvKey] = "ollama"
cfg["env"] = envCfg
modelProviders := qwenMap(cfg["modelProviders"])
modelProviders["openai"] = qwenMergeOpenAIProviders(modelProviders["openai"], qwenProvider(model))
cfg["modelProviders"] = modelProviders
security := qwenMap(cfg["security"])
auth := qwenMap(security["auth"])
auth["selectedType"] = "openai"
auth["baseUrl"] = qwenBaseURL()
security["auth"] = auth
cfg["security"] = security
modelCfg := qwenMap(cfg["model"])
modelCfg["name"] = model
cfg["model"] = modelCfg
}
func qwenMap(value any) map[string]any {
if m, ok := value.(map[string]any); ok {
return m
}
return map[string]any{}
}
func qwenMergeOpenAIProviders(value any, provider map[string]any) []any {
merged := []any{provider}
for _, existing := range qwenProviderList(value) {
if qwenIsOllamaProvider(existing) {
continue
}
merged = append(merged, existing)
}
return merged
}
func qwenProviderList(value any) []any {
switch providers := value.(type) {
case []any:
return providers
case []map[string]any:
out := make([]any, 0, len(providers))
for _, provider := range providers {
out = append(out, provider)
}
return out
default:
return nil
}
}
func qwenIsOllamaProvider(value any) bool {
provider, ok := value.(map[string]any)
if !ok {
return false
}
envKey, _ := provider["envKey"].(string)
baseURL, _ := provider["baseUrl"].(string)
return envKey == qwenOllamaEnvKey && strings.TrimRight(baseURL, "/") == qwenBaseURL()
}
func (q *Qwen) CurrentModel() string {
cfg, err := q.readConfig()
if err != nil {
return ""
}
if modelCfg, ok := cfg["model"].(map[string]any); ok {
if name, ok := modelCfg["name"].(string); ok {
return strings.TrimSpace(name)
}
}
modelProviders, ok := cfg["modelProviders"].(map[string]any)
if !ok {
return ""
}
providers, ok := modelProviders["openai"].([]any)
if !ok || len(providers) == 0 {
return ""
}
provider, ok := providers[0].(map[string]any)
if !ok {
return ""
}
name, _ := provider["id"].(string)
return strings.TrimSpace(name)
}
func (q *Qwen) Onboard() error {
return config.MarkIntegrationOnboarded("qwen")
}
func (q *Qwen) RequiresInteractiveOnboarding() bool { return false }
func (q *Qwen) configPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("could not determine config path")
}
return filepath.Join(home, ".qwen", "settings.json"), nil
}
func (q *Qwen) readConfig() (map[string]any, error) {
configPath, err := q.configPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(configPath)
if err != nil {
if os.IsNotExist(err) {
return map[string]any{}, nil
}
return nil, err
}
cfg := map[string]any{}
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse qwen config: %w", err)
}
return cfg, nil
}
func qwenBaseURL() string {
return strings.TrimRight(envconfig.Host().String(), "/") + "/v1"
}
func qwenProvider(model string) map[string]any {
return map[string]any{
"id": model,
"name": fmt.Sprintf("%s (Ollama)", model),
"baseUrl": qwenBaseURL(),
"envKey": qwenOllamaEnvKey,
}
}
func qwenLaunchArgs(model string, args []string) []string {
launchArgs := append([]string{}, args...)
if !qwenHasFlag(launchArgs, "--auth-type") {
launchArgs = append([]string{"--auth-type", "openai"}, launchArgs...)
}
if model != "" && !qwenHasFlag(launchArgs, "--model", "-m") {
launchArgs = append([]string{"--model", model}, launchArgs...)
}
return launchArgs
}
func qwenLaunchEnv(model string) []string {
env := os.Environ()
env = qwenUpsertEnv(env, "OPENAI_API_KEY", "ollama")
env = qwenUpsertEnv(env, "OPENAI_BASE_URL", qwenBaseURL())
if model != "" {
env = qwenUpsertEnv(env, "OPENAI_MODEL", model)
}
return env
}
func qwenUpsertEnv(env []string, key, value string) []string {
prefix := key + "="
filtered := env[:0]
for _, entry := range env {
if strings.HasPrefix(entry, prefix) {
continue
}
filtered = append(filtered, entry)
}
return append(filtered, prefix+value)
}
func qwenHasFlag(args []string, names ...string) bool {
for _, arg := range args {
for _, name := range names {
if arg == name || strings.HasPrefix(arg, name+"=") {
return true
}
}
}
return false
}
+862
View File
@@ -0,0 +1,862 @@
package launch
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"github.com/ollama/ollama/cmd/config"
"github.com/ollama/ollama/cmd/internal/fileutil"
)
func setQwenTestHome(t *testing.T, home string) {
t.Helper()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
}
func TestQwenConfigure(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
q := &Qwen{}
if err := q.Configure("gemma4"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
data, err := os.ReadFile(filepath.Join(tmpDir, ".qwen", "settings.json"))
if err != nil {
t.Fatalf("failed to read config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("failed to parse config: %v", err)
}
envCfg := cfg["env"].(map[string]any)
if envCfg[qwenOllamaEnvKey] != "ollama" {
t.Fatalf("expected env[%q] to be ollama, got %v", qwenOllamaEnvKey, envCfg[qwenOllamaEnvKey])
}
modelCfg := cfg["model"].(map[string]any)
if modelCfg["name"] != "gemma4" {
t.Fatalf("expected model.name gemma4, got %v", modelCfg["name"])
}
security := cfg["security"].(map[string]any)
auth := security["auth"].(map[string]any)
if auth["selectedType"] != "openai" {
t.Fatalf("expected auth.selectedType openai, got %v", auth["selectedType"])
}
if auth["baseUrl"] != qwenBaseURL() {
t.Fatalf("expected auth.baseUrl %q, got %v", qwenBaseURL(), auth["baseUrl"])
}
modelProviders := cfg["modelProviders"].(map[string]any)
openai := modelProviders["openai"].([]any)
if len(openai) != 1 {
t.Fatalf("expected one openai provider, got %d", len(openai))
}
provider := openai[0].(map[string]any)
if provider["id"] != "gemma4" {
t.Fatalf("expected provider id gemma4, got %v", provider["id"])
}
if provider["name"] != "gemma4 (Ollama)" {
t.Fatalf("expected provider name %q, got %v", "gemma4 (Ollama)", provider["name"])
}
if provider["baseUrl"] != qwenBaseURL() {
t.Fatalf("expected provider baseUrl %q, got %v", qwenBaseURL(), provider["baseUrl"])
}
if provider["envKey"] != qwenOllamaEnvKey {
t.Fatalf("expected provider envKey %q, got %v", qwenOllamaEnvKey, provider["envKey"])
}
}
func TestQwenConfigureBacksUpUnderIntegrationDirectory(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "settings.json")
if err := os.WriteFile(configPath, []byte(`{"original":true}`), 0o644); err != nil {
t.Fatalf("failed to write initial config: %v", err)
}
if err := (&Qwen{}).Configure("gemma4"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
backups, err := filepath.Glob(filepath.Join(fileutil.BackupDir(), "qwen", "settings.json.*"))
if err != nil {
t.Fatalf("failed to glob backups: %v", err)
}
for _, backup := range backups {
data, err := os.ReadFile(backup)
if err != nil {
t.Fatalf("failed to read backup: %v", err)
}
if string(data) == `{"original":true}` {
return
}
}
t.Fatalf("backup with original content not found in %v", backups)
}
func TestQwenConfigureMergesWithExistingSettings(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "settings.json")
initialConfig := []byte(`{
"theme": "dark",
"env": {
"OPENROUTER_API_KEY": "openrouter-key",
"OLLAMA_API_KEY": "old-ollama-key"
},
"modelProviders": {
"openai": [
{
"id": "old-ollama",
"name": "old-ollama (Ollama)",
"envKey": "OLLAMA_API_KEY",
"baseUrl": "` + qwenBaseURL() + `"
},
{
"id": "openrouter/model",
"name": "OpenRouter Model",
"envKey": "OPENROUTER_API_KEY",
"baseUrl": "https://openrouter.ai/api/v1",
"customField": "preserved"
},
{
"id": "remote-ollama",
"name": "Remote Ollama",
"envKey": "OLLAMA_API_KEY",
"baseUrl": "http://10.0.0.20:11434/v1"
}
],
"gemini": [
{
"id": "gemini-2.5-pro",
"envKey": "GEMINI_API_KEY"
}
]
},
"security": {
"auth": {
"selectedType": "qwen-oauth",
"baseUrl": "https://old.example/v1",
"customAuthField": "preserved"
},
"trustedFolders": ["/tmp/project"]
},
"model": {
"name": "old-ollama",
"generationConfig": {
"temperature": 0.2
}
}
}`)
if err := os.WriteFile(configPath, initialConfig, 0o644); err != nil {
t.Fatalf("failed to write initial config: %v", err)
}
if err := (&Qwen{}).Configure("gemma4"); err != nil {
t.Fatalf("expected no error, got %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config: %v", err)
}
var cfg map[string]any
if err := json.Unmarshal(data, &cfg); err != nil {
t.Fatalf("failed to parse config: %v", err)
}
if cfg["theme"] != "dark" {
t.Fatalf("expected top-level theme to be preserved, got %v", cfg["theme"])
}
envCfg := cfg["env"].(map[string]any)
if envCfg["OPENROUTER_API_KEY"] != "openrouter-key" {
t.Fatalf("expected OPENROUTER_API_KEY to be preserved, got %v", envCfg["OPENROUTER_API_KEY"])
}
if envCfg[qwenOllamaEnvKey] != "ollama" {
t.Fatalf("expected %s to be updated, got %v", qwenOllamaEnvKey, envCfg[qwenOllamaEnvKey])
}
modelProviders := cfg["modelProviders"].(map[string]any)
gemini := modelProviders["gemini"].([]any)
if len(gemini) != 1 {
t.Fatalf("expected gemini providers to be preserved, got %v", gemini)
}
openai := modelProviders["openai"].([]any)
if len(openai) != 3 {
t.Fatalf("expected new Ollama provider plus preserved OpenRouter and remote Ollama providers, got %v", openai)
}
ollamaProvider := openai[0].(map[string]any)
if ollamaProvider["id"] != "gemma4" {
t.Fatalf("expected Ollama provider to update to gemma4, got %v", ollamaProvider["id"])
}
openRouterProvider := openai[1].(map[string]any)
if openRouterProvider["id"] != "openrouter/model" {
t.Fatalf("expected OpenRouter provider to be preserved, got %v", openRouterProvider["id"])
}
if openRouterProvider["customField"] != "preserved" {
t.Fatalf("expected OpenRouter custom field to be preserved, got %v", openRouterProvider["customField"])
}
remoteOllamaProvider := openai[2].(map[string]any)
if remoteOllamaProvider["id"] != "remote-ollama" {
t.Fatalf("expected remote Ollama provider to be preserved, got %v", remoteOllamaProvider["id"])
}
security := cfg["security"].(map[string]any)
auth := security["auth"].(map[string]any)
if auth["selectedType"] != "openai" {
t.Fatalf("expected selectedType openai, got %v", auth["selectedType"])
}
if auth["baseUrl"] != qwenBaseURL() {
t.Fatalf("expected auth.baseUrl %q, got %v", qwenBaseURL(), auth["baseUrl"])
}
if auth["customAuthField"] != "preserved" {
t.Fatalf("expected custom auth field to be preserved, got %v", auth["customAuthField"])
}
if len(security["trustedFolders"].([]any)) != 1 {
t.Fatalf("expected security.trustedFolders to be preserved, got %v", security["trustedFolders"])
}
modelCfg := cfg["model"].(map[string]any)
if modelCfg["name"] != "gemma4" {
t.Fatalf("expected model.name gemma4, got %v", modelCfg["name"])
}
generationConfig := modelCfg["generationConfig"].(map[string]any)
if generationConfig["temperature"] != 0.2 {
t.Fatalf("expected model generationConfig to be preserved, got %v", generationConfig)
}
}
func TestQwenCurrentModel(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
q := &Qwen{}
if got := q.CurrentModel(); got != "" {
t.Fatalf("expected empty model without config, got %q", got)
}
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "settings.json")
if err := os.WriteFile(configPath, []byte(`{"model":{"name":"llama3.2"}}`), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
if got := q.CurrentModel(); got != "llama3.2" {
t.Fatalf("expected current model llama3.2, got %q", got)
}
}
func TestQwenCurrentModelFallsBackToProvider(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
configPath := filepath.Join(configDir, "settings.json")
if err := os.WriteFile(configPath, []byte(`{"modelProviders":{"openai":[{"id":"mistral"}]}}`), 0o644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
if got := (&Qwen{}).CurrentModel(); got != "mistral" {
t.Fatalf("expected provider fallback mistral, got %q", got)
}
}
func TestQwenOnboard(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
if err := (&Qwen{}).Onboard(); err != nil {
t.Fatalf("expected no error, got %v", err)
}
saved, err := config.LoadIntegration("qwen")
if err != nil {
t.Fatalf("failed to load integration config: %v", err)
}
if !saved.Onboarded {
t.Fatal("expected qwen integration to be marked onboarded")
}
}
func TestQwenIntegration(t *testing.T) {
q := &Qwen{}
t.Run("String", func(t *testing.T) {
if got := q.String(); got != "Qwen Code" {
t.Fatalf("String() = %q, want %q", got, "Qwen Code")
}
})
t.Run("implements Runner", func(t *testing.T) {
var _ Runner = q
})
t.Run("implements ManagedSingleModel", func(t *testing.T) {
var _ ManagedSingleModel = q
})
t.Run("implements ManagedInteractiveOnboarding", func(t *testing.T) {
var _ ManagedInteractiveOnboarding = q
})
}
func TestQwenFindPath(t *testing.T) {
q := &Qwen{}
path, err := q.findPath()
if err != nil {
t.Skipf("qwen binary not found, skipping: %v", err)
}
if path == "" {
t.Fatal("expected non-empty path")
}
}
func TestQwenPaths(t *testing.T) {
testDir := filepath.Join(t.TempDir(), "qwen-paths-test")
setQwenTestHome(t, testDir)
q := &Qwen{}
os.MkdirAll(filepath.Join(testDir, ".qwen"), 0o755)
os.WriteFile(filepath.Join(testDir, ".qwen", "settings.json"), []byte("{}"), 0o644)
paths := q.Paths()
if len(paths) != 1 {
t.Fatalf("expected 1 path, got %v", paths)
}
want, err := filepath.EvalSymlinks(filepath.Join(testDir, ".qwen", "settings.json"))
if err != nil {
t.Fatalf("failed to resolve expected path: %v", err)
}
got, err := filepath.EvalSymlinks(paths[0])
if err != nil {
t.Fatalf("failed to resolve returned path: %v", err)
}
if got != want {
t.Fatalf("expected user config path %s, got %s", want, got)
}
}
func TestQwenLaunchArgs(t *testing.T) {
got := qwenLaunchArgs("llama3.2", nil)
want := []string{"--model", "llama3.2", "--auth-type", "openai"}
if !slices.Equal(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
got = qwenLaunchArgs("llama3.2", []string{"--auth-type", "openai"})
want = []string{"--model", "llama3.2", "--auth-type", "openai"}
if !slices.Equal(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
got = qwenLaunchArgs("llama3.2", []string{"-m", "gemma4"})
want = []string{"--auth-type", "openai", "-m", "gemma4"}
if !slices.Equal(got, want) {
t.Fatalf("expected %v, got %v", want, got)
}
}
func TestQwenLaunchEnv(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "")
t.Setenv("OPENAI_BASE_URL", "")
t.Setenv("OPENAI_MODEL", "")
env := qwenLaunchEnv("llama3.2")
if !slices.Contains(env, "OPENAI_API_KEY=ollama") {
t.Fatalf("expected OPENAI_API_KEY override, got %v", env)
}
if !slices.Contains(env, "OPENAI_BASE_URL="+qwenBaseURL()) {
t.Fatalf("expected OPENAI_BASE_URL override, got %v", env)
}
if !slices.Contains(env, "OPENAI_MODEL=llama3.2") {
t.Fatalf("expected OPENAI_MODEL override, got %v", env)
}
}
func TestQwenLaunchEnvOverridesExistingOpenAIEnv(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "real-key")
t.Setenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
t.Setenv("OPENAI_MODEL", "gpt-4.1")
env := qwenLaunchEnv("llama3.2")
if !slices.Contains(env, "OPENAI_API_KEY=ollama") {
t.Fatalf("expected OPENAI_API_KEY override, got %v", env)
}
if !slices.Contains(env, "OPENAI_BASE_URL="+qwenBaseURL()) {
t.Fatalf("expected OPENAI_BASE_URL override, got %v", env)
}
if !slices.Contains(env, "OPENAI_MODEL=llama3.2") {
t.Fatalf("expected OPENAI_MODEL override, got %v", env)
}
}
func TestQwenRunDoesNotRewriteConfig(t *testing.T) {
tmpDir := t.TempDir()
setQwenTestHome(t, tmpDir)
t.Chdir(tmpDir)
qwenBinDir := filepath.Join(tmpDir, "bin")
if err := os.MkdirAll(qwenBinDir, 0o755); err != nil {
t.Fatalf("failed to create bin dir: %v", err)
}
qwenBin := filepath.Join(qwenBinDir, "qwen")
qwenScript := "#!/bin/sh\nexit 0\n"
if runtime.GOOS == "windows" {
qwenBin = filepath.Join(qwenBinDir, "qwen.bat")
qwenScript = "@echo off\r\nexit /b 0\r\n"
}
if err := os.WriteFile(qwenBin, []byte(qwenScript), 0o755); err != nil {
t.Fatalf("failed to write fake qwen binary: %v", err)
}
if runtime.GOOS != "windows" {
if err := os.Chmod(qwenBin, 0o755); err != nil {
t.Fatalf("failed to chmod fake qwen binary: %v", err)
}
}
t.Setenv("PATH", qwenBinDir+string(os.PathListSeparator)+os.Getenv("PATH"))
configDir := filepath.Join(tmpDir, ".qwen")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("failed to create config dir: %v", err)
}
initialConfig := []byte(`{"model":{"name":"qwen3:32b"}}`)
configPath := filepath.Join(configDir, "settings.json")
if err := os.WriteFile(configPath, initialConfig, 0o644); err != nil {
t.Fatalf("failed to write initial config: %v", err)
}
if err := (&Qwen{}).Run("qwen3:32b", nil, nil); err != nil {
t.Fatalf("Run() error = %v", err)
}
data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("failed to read config after run: %v", err)
}
if string(data) != string(initialConfig) {
t.Fatalf("expected run not to rewrite config, got %s", string(data))
}
}
func TestEnsureQwenInstalled(t *testing.T) {
oldGOOS := qwenGOOS
t.Cleanup(func() { qwenGOOS = oldGOOS })
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
t.Helper()
oldConfirm := DefaultConfirmPrompt
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
return fn(prompt)
}
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
}
t.Run("already installed", func(t *testing.T) {
setQwenTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
writeFakeBinary(t, tmpDir, "qwen")
qwenGOOS = runtime.GOOS
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
bin, err := ensureQwenInstalled()
if err != nil {
t.Fatalf("ensureQwenInstalled() error = %v", err)
}
if filepath.Base(bin) == "" {
t.Fatalf("expected qwen binary path, got %q", bin)
}
})
t.Run("missing dependencies", func(t *testing.T) {
setQwenTestHome(t, t.TempDir())
t.Setenv("PATH", t.TempDir())
qwenGOOS = "linux"
withConfirm(t, func(prompt string) (bool, error) {
t.Fatalf("did not expect prompt, got %q", prompt)
return false, nil
})
_, err := ensureQwenInstalled()
if err == nil || !strings.Contains(err.Error(), "required dependencies are missing") {
t.Fatalf("expected missing dependency error, got %v", err)
}
})
t.Run("missing and user declines install", func(t *testing.T) {
setQwenTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
writeFakeBinary(t, tmpDir, "curl")
writeFakeBinary(t, tmpDir, "bash")
qwenGOOS = "linux"
withConfirm(t, func(prompt string) (bool, error) {
if !strings.Contains(prompt, "Qwen Code is not installed.") {
t.Fatalf("unexpected prompt: %q", prompt)
}
return false, nil
})
_, err := ensureQwenInstalled()
if err == nil || !strings.Contains(err.Error(), "installation cancelled") {
t.Fatalf("expected cancellation error, got %v", err)
}
})
t.Run("missing and user confirms unix install succeeds", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
qwenGOOS = "linux"
writeFakeBinary(t, tmpDir, "curl")
installLog := filepath.Join(tmpDir, "bash.log")
qwenPath := filepath.Join(homeDir, ".npm-global", "bin", "qwen")
bashScript := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
if [ "$1" = "-c" ]; then
/bin/mkdir -p %q
/bin/cat > %q <<'EOS'
#!/bin/sh
exit 0
EOS
/bin/chmod +x %q
fi
exit 0
`, installLog, filepath.Dir(qwenPath), qwenPath, qwenPath)
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte(bashScript), 0o755); err != nil {
t.Fatalf("failed to write fake bash: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
bin, err := ensureQwenInstalled()
if err != nil {
t.Fatalf("ensureQwenInstalled() error = %v", err)
}
if bin != qwenPath {
t.Fatalf("bin = %q, want %q", bin, qwenPath)
}
logData, err := os.ReadFile(installLog)
if err != nil {
t.Fatalf("failed to read install log: %v", err)
}
if !strings.Contains(string(logData), "install-qwen.sh") {
t.Fatalf("expected install-qwen.sh command in log, got:\n%s", string(logData))
}
if !strings.Contains(string(logData), "exec qwen/d") {
t.Fatalf("expected command to remove installer auto-start block, got:\n%s", string(logData))
}
})
t.Run("missing and user confirms windows install succeeds", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
appData := filepath.Join(homeDir, "AppData", "Roaming")
t.Setenv("APPDATA", appData)
t.Setenv("LOCALAPPDATA", filepath.Join(homeDir, "AppData", "Local"))
qwenGOOS = "windows"
installLog := filepath.Join(tmpDir, "powershell.log")
qwenPath := filepath.Join(appData, "npm", "qwen.cmd")
powershellScript := fmt.Sprintf(`#!/bin/sh
echo "$@" >> %q
/bin/mkdir -p %q
/bin/cat > %q <<'EOS'
@echo off
exit /b 0
EOS
/bin/chmod +x %q
exit 0
`, installLog, filepath.Dir(qwenPath), qwenPath, qwenPath)
if err := os.WriteFile(filepath.Join(tmpDir, "powershell"), []byte(powershellScript), 0o755); err != nil {
t.Fatalf("failed to write fake powershell: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
bin, err := ensureQwenInstalled()
if err != nil {
t.Fatalf("ensureQwenInstalled() error = %v", err)
}
if bin != qwenPath {
t.Fatalf("bin = %q, want %q", bin, qwenPath)
}
logData, err := os.ReadFile(installLog)
if err != nil {
t.Fatalf("failed to read install log: %v", err)
}
if !strings.Contains(string(logData), "install-qwen.bat") {
t.Fatalf("expected install-qwen.bat command in log, got:\n%s", string(logData))
}
if !strings.Contains(string(logData), "REM call qwen") {
t.Fatalf("expected command to replace installer auto-start call, got:\n%s", string(logData))
}
})
t.Run("install command fails", func(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("uses POSIX shell fake binaries")
}
setQwenTestHome(t, t.TempDir())
tmpDir := t.TempDir()
t.Setenv("PATH", tmpDir)
qwenGOOS = "linux"
writeFakeBinary(t, tmpDir, "curl")
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
t.Fatalf("failed to write fake bash: %v", err)
}
withConfirm(t, func(prompt string) (bool, error) {
return true, nil
})
_, err := ensureQwenInstalled()
if err == nil || !strings.Contains(err.Error(), "failed to install qwen") {
t.Fatalf("expected install failure error, got %v", err)
}
})
}
func TestQwenFindPathFallbacks(t *testing.T) {
oldGOOS := qwenGOOS
t.Cleanup(func() { qwenGOOS = oldGOOS })
t.Run("unix npm global bin", func(t *testing.T) {
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
t.Setenv("PATH", t.TempDir())
qwenGOOS = "linux"
target := filepath.Join(homeDir, ".npm-global", "bin", "qwen")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("failed to create qwen dir: %v", err)
}
if err := os.WriteFile(target, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("failed to write qwen binary: %v", err)
}
got, err := (&Qwen{}).findPath()
if err != nil {
t.Fatalf("findPath() error = %v", err)
}
if got != target {
t.Fatalf("findPath() = %q, want %q", got, target)
}
})
t.Run("windows appdata npm shim", func(t *testing.T) {
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
t.Setenv("PATH", t.TempDir())
appData := filepath.Join(homeDir, "AppData", "Roaming")
t.Setenv("APPDATA", appData)
t.Setenv("LOCALAPPDATA", filepath.Join(homeDir, "AppData", "Local"))
qwenGOOS = "windows"
target := filepath.Join(appData, "npm", "qwen.cmd")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("failed to create qwen dir: %v", err)
}
if err := os.WriteFile(target, []byte("@echo off\r\nexit /b 0\r\n"), 0o755); err != nil {
t.Fatalf("failed to write qwen shim: %v", err)
}
got, err := (&Qwen{}).findPath()
if err != nil {
t.Fatalf("findPath() error = %v", err)
}
if got != target {
t.Fatalf("findPath() = %q, want %q", got, target)
}
})
t.Run("unix nvm npm bin", func(t *testing.T) {
homeDir := t.TempDir()
setQwenTestHome(t, homeDir)
t.Setenv("PATH", t.TempDir())
qwenGOOS = "linux"
target := filepath.Join(homeDir, ".nvm", "versions", "node", "v20.18.1", "bin", "qwen")
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
t.Fatalf("failed to create qwen dir: %v", err)
}
if err := os.WriteFile(target, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("failed to write qwen binary: %v", err)
}
got, err := (&Qwen{}).findPath()
if err != nil {
t.Fatalf("findPath() error = %v", err)
}
if got != target {
t.Fatalf("findPath() = %q, want %q", got, target)
}
})
}
func TestQwenInstallShimDir(t *testing.T) {
oldGOOS := qwenGOOS
t.Cleanup(func() { qwenGOOS = oldGOOS })
t.Run("unix shim", func(t *testing.T) {
qwenGOOS = "linux"
dir, cleanup, err := qwenInstallShimDir()
if err != nil {
t.Fatalf("qwenInstallShimDir() error = %v", err)
}
defer cleanup()
if _, err := os.Stat(filepath.Join(dir, "qwen")); err != nil {
t.Fatalf("expected qwen shim: %v", err)
}
})
t.Run("windows shim", func(t *testing.T) {
qwenGOOS = "windows"
dir, cleanup, err := qwenInstallShimDir()
if err != nil {
t.Fatalf("qwenInstallShimDir() error = %v", err)
}
defer cleanup()
for _, name := range []string{"qwen.cmd", "qwen.bat"} {
if _, err := os.Stat(filepath.Join(dir, name)); err != nil {
t.Fatalf("expected %s shim: %v", name, err)
}
}
})
}
func TestQwenInstallerEnvPrependsShimPath(t *testing.T) {
env := qwenInstallerEnv([]string{"FOO=bar", "PATH=/usr/bin"}, "/tmp/qwen-shim")
if !slices.Contains(env, "FOO=bar") {
t.Fatalf("expected unrelated env to be preserved, got %v", env)
}
if !slices.Contains(env, "PATH=/tmp/qwen-shim"+string(os.PathListSeparator)+"/usr/bin") {
t.Fatalf("expected shim path to be prepended, got %v", env)
}
env = qwenInstallerEnv([]string{"Path=C:\\Windows"}, "C:\\qwen-shim")
if !slices.Contains(env, "Path=C:\\qwen-shim"+string(os.PathListSeparator)+"C:\\Windows") {
t.Fatalf("expected existing Path casing to be preserved, got %v", env)
}
}
func TestQwenInstallerCommand(t *testing.T) {
tests := []struct {
name string
goos string
wantBin string
wantParts []string
wantErr bool
}{
{
name: "linux",
goos: "linux",
wantBin: "bash",
wantParts: []string{"-c", "install-qwen.sh", "sed", "exec qwen/d"},
},
{
name: "darwin",
goos: "darwin",
wantBin: "bash",
wantParts: []string{"-c", "install-qwen.sh", "sed", "exec qwen/d"},
},
{
name: "windows",
goos: "windows",
wantBin: "powershell",
wantParts: []string{"-Command", "-UseBasicParsing", "-OutFile", "Get-Content -Raw", "install-qwen.bat", "REM call qwen"},
},
{
name: "unsupported",
goos: "freebsd",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
bin, args, err := qwenInstallerCommand(tt.goos)
if tt.wantErr {
if err == nil {
t.Fatal("expected error")
}
return
}
if err != nil {
t.Fatalf("qwenInstallerCommand() error = %v", err)
}
if bin != tt.wantBin {
t.Fatalf("bin = %q, want %q", bin, tt.wantBin)
}
joined := strings.Join(args, " ")
for _, part := range tt.wantParts {
if !strings.Contains(joined, part) {
t.Fatalf("args %q missing %q", joined, part)
}
}
})
}
}
+49 -4
View File
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
Description string
}
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "droid", "pi", "pool"}
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp", "cline", "droid", "pi", "pool", "qwen"}
var integrationSpecs = []*IntegrationSpec{
{
@@ -65,13 +65,16 @@ var integrationSpecs = []*IntegrationSpec{
Name: "cline",
Runner: &Cline{},
Description: "Autonomous coding agent with parallel execution",
Hidden: true,
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := exec.LookPath("cline")
return err == nil
},
Command: []string{"npm", "install", "-g", "cline"},
EnsureInstalled: func() error {
_, err := ensureClineInstalled()
return err
},
Command: []string{"npm", "install", "-g", "cline@latest"},
},
},
{
@@ -153,6 +156,18 @@ var integrationSpecs = []*IntegrationSpec{
URL: "https://opencode.ai",
},
},
{
Name: "omp",
Runner: &OMP{},
Description: "AI coding agent with IDE integration",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := (&OMP{}).findPath()
return err == nil
},
URL: "https://omp.sh",
},
},
{
Name: "openclaw",
Runner: &Openclaw{},
@@ -188,7 +203,7 @@ var integrationSpecs = []*IntegrationSpec{
_, err := ensurePiInstalled()
return err
},
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"},
Command: []string{"npm", "install", "-g", "@earendil-works/pi-coding-agent@latest"},
},
},
{
@@ -217,6 +232,20 @@ var integrationSpecs = []*IntegrationSpec{
URL: "https://hermes-agent.nousresearch.com/docs/getting-started/installation/",
},
},
{
Name: "hermes-desktop",
Runner: &HermesDesktop{},
Description: "Desktop app for Hermes Agent by Nous Research",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
return (&Hermes{}).installed()
},
EnsureInstalled: func() error {
return (&Hermes{}).ensureInstalledFor("hermes-desktop")
},
URL: "https://hermes-agent.nousresearch.com/docs/getting-started/installation/",
},
},
{
Name: "vscode",
Runner: &VSCode{},
@@ -230,6 +259,22 @@ var integrationSpecs = []*IntegrationSpec{
URL: "https://code.visualstudio.com",
},
},
{
Name: "qwen",
Runner: &Qwen{},
Description: "Qwen's AI coding agent with tool use",
Install: IntegrationInstallSpec{
CheckInstalled: func() bool {
_, err := (&Qwen{}).findPath()
return err == nil
},
EnsureInstalled: func() error {
_, err := ensureQwenInstalled()
return err
},
URL: "https://qwen.ai/qwencode",
},
},
}
var integrationSpecsByName map[string]*IntegrationSpec
+8
View File
@@ -61,6 +61,14 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
return filepath.Join(home, ".kimi", "config.toml")
},
},
{
name: "omp",
binary: "omp",
runner: &OMP{},
checkPath: func(home string) string {
return filepath.Join(home, ".omp", "agent", "models.yml")
},
},
}
for _, tt := range tests {
+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
+683 -8
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,11 +559,386 @@ 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
merges := make([]merge, q.NumHiddenLayers*3)
for i := range q.NumHiddenLayers {
ts = q.renameMTPLayerTensors(ts)
blockCount := q.NumHiddenLayers + q.NumNextNPredictLayers
merges := make([]merge, blockCount*3)
for i := range blockCount {
merges[i*3+0] = merge{
fmt.Sprintf("blk.%d.mlp.experts.*.gate_proj.weight", i),
fmt.Sprintf("blk.%d.ffn_gate_exps.weight", i),
@@ -398,6 +960,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 +1033,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 +1068,112 @@ func (q *qwen3NextModel) Tensors(ts []Tensor) []*ggml.Tensor {
return out
}
func (q *qwen3NextModel) renameMTPLayerTensors(ts []Tensor) []Tensor {
var out []Tensor
for i, t := range ts {
name, ok := q.mtpLayerTensorName(t.Name())
if !ok {
continue
}
if out == nil {
out = slices.Clone(ts)
}
out[i] = &renamedTensor{Tensor: t, name: name}
}
if out != nil {
return out
}
return ts
}
func (q *qwen3NextModel) mtpLayerTensorName(name string) (string, bool) {
rest := strings.TrimPrefix(name, "mtp.layers.")
if rest == name {
return "", false
}
layer, suffix, ok := strings.Cut(rest, ".")
if !ok {
return "", false
}
idx, err := strconv.ParseUint(layer, 10, 32)
if err != nil {
return "", false
}
return fmt.Sprintf("blk.%d.%s", q.NumHiddenLayers+uint32(idx), suffix), true
}
type renamedTensor struct {
Tensor
name string
}
func (t *renamedTensor) Name() string {
return t.name
}
func (t *renamedTensor) Clone() Tensor {
return &renamedTensor{Tensor: t.Tensor.Clone(), name: t.name}
}
func (t *renamedTensor) SourceDType() string {
return sourceDType(t.Tensor)
}
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
}
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 +1600,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",
+382 -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{
@@ -543,6 +819,101 @@ func TestQwen35MoePackedExperts(t *testing.T) {
}
}
func TestQwen35MTPMoePackedExperts(t *testing.T) {
m := &qwen3NextModel{
qwen3NextTextConfig: qwen3NextTextConfig{
NumHiddenLayers: 40,
NumNextNPredictLayers: 1,
},
}
out := m.Tensors([]Tensor{
&fakeTensor{
name: "mtp.layers.0.mlp.experts.gate_up_proj",
shape: []uint64{2, 4, 3},
data: []float32{
0, 1, 2,
3, 4, 5,
6, 7, 8,
9, 10, 11,
12, 13, 14,
15, 16, 17,
18, 19, 20,
21, 22, 23,
},
},
&fakeTensor{
name: "mtp.layers.0.mlp.experts.down_proj",
shape: []uint64{2, 5, 3},
data: make([]float32, 2*5*3),
},
})
byName := map[string]*ggml.Tensor{}
for _, tensor := range out {
if strings.Contains(tensor.Name, ".mlp.experts.") {
t.Fatalf("unexpected raw expert tensor %q", tensor.Name)
}
byName[tensor.Name] = tensor
}
gate := byName["blk.40.ffn_gate_exps.weight"]
if gate == nil {
t.Fatalf("missing tensor %q", "blk.40.ffn_gate_exps.weight")
}
if got, want := gate.Shape, []uint64{2, 2, 3}; !slices.Equal(got, want) {
t.Fatalf("unexpected gate shape: got %v want %v", got, want)
}
if got, want := readTensorData(t, gate), []float32{
0, 1, 2, 3, 4, 5,
12, 13, 14, 15, 16, 17,
}; !slices.Equal(got, want) {
t.Fatalf("unexpected gate values: got %v want %v", got, want)
}
if _, ok := byName["blk.40.ffn_up_exps.weight"]; !ok {
t.Fatalf("missing tensor %q", "blk.40.ffn_up_exps.weight")
}
if _, ok := byName["blk.40.ffn_down_exps.weight"]; !ok {
t.Fatalf("missing tensor %q", "blk.40.ffn_down_exps.weight")
}
}
func TestQwen35MTPMoePerExpertTensors(t *testing.T) {
m := &qwen3NextModel{
qwen3NextTextConfig: qwen3NextTextConfig{
NumHiddenLayers: 40,
NumNextNPredictLayers: 1,
},
}
out := m.Tensors([]Tensor{
&fakeTensor{
name: "mtp.layers.0.mlp.experts.1.gate_proj.weight",
shape: []uint64{2, 2},
data: []float32{10, 11, 12, 13},
},
&fakeTensor{
name: "mtp.layers.0.mlp.experts.0.gate_proj.weight",
shape: []uint64{2, 2},
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 := out[0].Name, "blk.40.ffn_gate_exps.weight"; got != want {
t.Fatalf("unexpected tensor name: got %q want %q", got, want)
}
if got, want := out[0].Shape, []uint64{2, 2, 2}; !slices.Equal(got, want) {
t.Fatalf("unexpected tensor shape: got %v want %v", got, want)
}
if got, want := readTensorData(t, out[0]), []float32{0, 1, 2, 3, 10, 11, 12, 13}; !slices.Equal(got, want) {
t.Fatalf("unexpected tensor values: got %v want %v", got, want)
}
}
func TestQwen35SharedExpertGateKeepsMatrixShape(t *testing.T) {
m := &qwen3NextModel{}
+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")
File diff suppressed because it is too large. Load diff
+3 -185
View File
@@ -2,11 +2,8 @@ package discover
import (
"fmt"
"log/slog"
"syscall"
"unsafe"
"github.com/ollama/ollama/logutil"
)
type MEMORYSTATUSEX struct {
@@ -22,10 +19,9 @@ type MEMORYSTATUSEX struct {
}
var (
k32 = syscall.NewLazyDLL("kernel32.dll")
globalMemoryStatusExProc = k32.NewProc("GlobalMemoryStatusEx")
sizeofMemoryStatusEx = uint32(unsafe.Sizeof(MEMORYSTATUSEX{}))
GetLogicalProcessorInformationEx = k32.NewProc("GetLogicalProcessorInformationEx")
k32 = syscall.NewLazyDLL("kernel32.dll")
globalMemoryStatusExProc = k32.NewProc("GlobalMemoryStatusEx")
sizeofMemoryStatusEx = uint32(unsafe.Sizeof(MEMORYSTATUSEX{}))
)
func GetCPUMem() (memInfo, error) {
@@ -37,184 +33,6 @@ func GetCPUMem() (memInfo, error) {
return memInfo{TotalMemory: memStatus.TotalPhys, FreeMemory: memStatus.AvailPhys, FreeSwap: memStatus.AvailPageFile}, nil
}
type LOGICAL_PROCESSOR_RELATIONSHIP uint32
const (
RelationProcessorCore LOGICAL_PROCESSOR_RELATIONSHIP = iota
RelationNumaNode
RelationCache
RelationProcessorPackage
RelationGroup
RelationProcessorDie
RelationNumaNodeEx
RelationProcessorModule
)
const RelationAll LOGICAL_PROCESSOR_RELATIONSHIP = 0xffff
type GROUP_AFFINITY struct {
Mask uintptr // KAFFINITY
Group uint16
Reserved [3]uint16
}
type PROCESSOR_RELATIONSHIP struct {
Flags byte
EfficiencyClass byte
Reserved [20]byte
GroupCount uint16
GroupMask [1]GROUP_AFFINITY // len GroupCount
}
// Omitted unused structs: NUMA_NODE_RELATIONSHIP CACHE_RELATIONSHIP GROUP_RELATIONSHIP
type SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX struct {
Relationship LOGICAL_PROCESSOR_RELATIONSHIP
Size uint32
U [1]byte // Union len Size
// PROCESSOR_RELATIONSHIP
// NUMA_NODE_RELATIONSHIP
// CACHE_RELATIONSHIP
// GROUP_RELATIONSHIP
}
func (group *GROUP_AFFINITY) IsMember(target *GROUP_AFFINITY) bool {
if group == nil || target == nil {
return false
}
return group.Mask&target.Mask != 0
}
type winPackage struct {
groups []*GROUP_AFFINITY
coreCount int // performance cores = coreCount - efficiencyCoreCount
efficiencyCoreCount int
threadCount int
}
func (pkg *winPackage) IsMember(target *GROUP_AFFINITY) bool {
for _, group := range pkg.groups {
if group.IsMember(target) {
return true
}
}
return false
}
func getLogicalProcessorInformationEx() ([]byte, error) {
buf := make([]byte, 1)
bufSize := len(buf)
ret, _, err := GetLogicalProcessorInformationEx.Call(
uintptr(RelationAll),
uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(&bufSize)),
)
if ret != 0 {
logutil.Trace("failed to retrieve CPU payload size", "ret", ret, "size", bufSize, "error", err)
return nil, fmt.Errorf("failed to determine size info ret:%d %w", ret, err)
}
buf = make([]byte, bufSize)
ret, _, err = GetLogicalProcessorInformationEx.Call(
uintptr(RelationAll),
uintptr(unsafe.Pointer(&buf[0])),
uintptr(unsafe.Pointer(&bufSize)),
)
if ret == 0 {
logutil.Trace("failed to retrieve CPU information", "ret", ret, "size", len(buf), "new_size", bufSize, "error", err)
return nil, fmt.Errorf("failed to gather processor information ret:%d buflen:%d %w", ret, bufSize, err)
}
return buf, nil
}
func processSystemLogicalProcessorInforationList(buf []byte) []*winPackage {
var slpi *SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX
// Find all the packages first
packages := []*winPackage{}
for bufOffset := 0; bufOffset < len(buf); bufOffset += int(slpi.Size) {
slpi = (*SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX)(unsafe.Pointer(&buf[bufOffset]))
if slpi.Relationship != RelationProcessorPackage {
continue
}
pr := (*PROCESSOR_RELATIONSHIP)(unsafe.Pointer(&slpi.U[0]))
pkg := &winPackage{}
ga0 := unsafe.Pointer(&pr.GroupMask[0])
for j := range pr.GroupCount {
gm := (*GROUP_AFFINITY)(unsafe.Pointer(uintptr(ga0) + uintptr(j)*unsafe.Sizeof(GROUP_AFFINITY{})))
pkg.groups = append(pkg.groups, gm)
}
packages = append(packages, pkg)
}
slog.Info("packages", "count", len(packages))
// To identify efficiency cores we have to compare the relative values
// Larger values are "less efficient" (aka, more performant)
var maxEfficiencyClass byte
for bufOffset := 0; bufOffset < len(buf); bufOffset += int(slpi.Size) {
slpi = (*SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX)(unsafe.Pointer(&buf[bufOffset]))
if slpi.Relationship != RelationProcessorCore {
continue
}
pr := (*PROCESSOR_RELATIONSHIP)(unsafe.Pointer(&slpi.U[0]))
if pr.EfficiencyClass > maxEfficiencyClass {
maxEfficiencyClass = pr.EfficiencyClass
}
}
if maxEfficiencyClass > 0 {
slog.Info("efficiency cores detected", "maxEfficiencyClass", maxEfficiencyClass)
}
// then match up the Cores to the Packages, count up cores, threads and efficiency cores
for bufOffset := 0; bufOffset < len(buf); bufOffset += int(slpi.Size) {
slpi = (*SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX)(unsafe.Pointer(&buf[bufOffset]))
if slpi.Relationship != RelationProcessorCore {
continue
}
pr := (*PROCESSOR_RELATIONSHIP)(unsafe.Pointer(&slpi.U[0]))
ga0 := unsafe.Pointer(&pr.GroupMask[0])
for j := range pr.GroupCount {
gm := (*GROUP_AFFINITY)(unsafe.Pointer(uintptr(ga0) + uintptr(j)*unsafe.Sizeof(GROUP_AFFINITY{})))
for _, pkg := range packages {
if pkg.IsMember(gm) {
pkg.coreCount++
if pr.Flags == 0 {
pkg.threadCount++
} else {
pkg.threadCount += 2
}
if pr.EfficiencyClass < maxEfficiencyClass {
pkg.efficiencyCoreCount++
}
}
}
}
}
// Summarize the results
for i, pkg := range packages {
slog.Info("", "package", i, "cores", pkg.coreCount, "efficiency", pkg.efficiencyCoreCount, "threads", pkg.threadCount)
}
return packages
}
func GetCPUDetails() []CPU {
buf, err := getLogicalProcessorInformationEx()
if err != nil {
slog.Warn("failed to get CPU details", "error", err)
return nil
}
packages := processSystemLogicalProcessorInforationList(buf)
cpus := make([]CPU, len(packages))
for i, pkg := range packages {
cpus[i].CoreCount = pkg.coreCount
cpus[i].EfficiencyCoreCount = pkg.efficiencyCoreCount
cpus[i].ThreadCount = pkg.threadCount
}
return cpus
}
func IsNUMA() bool {
// numa support in ggml is linux only
return false
File diff suppressed because one or more lines are too long.
+54
View File
@@ -0,0 +1,54 @@
package discover
import (
"context"
"log/slog"
"github.com/ollama/ollama/ml"
)
func filterOldCUDADriver(_ context.Context, devices []ml.DeviceInfo) []ml.DeviceInfo {
oldCUDA := func(dev ml.DeviceInfo) bool {
return dev.Library == "CUDA" && dev.ComputeMajor > 0 && dev.ComputeMajor < 7
}
needsCheck := false
for _, dev := range devices {
if oldCUDA(dev) {
needsCheck = true
break
}
}
if !needsCheck {
return devices
}
driver := nvidiaDriverMajorFromDevices(devices)
if driver == 0 {
slog.Warn("could not verify NVIDIA driver compatibility for an older NVIDIA GPU")
return devices
}
if driver >= 570 {
return devices
}
filtered := devices[:0]
for _, dev := range devices {
if oldCUDA(dev) {
slog.Warn("NVIDIA driver too old",
"device", dev.Description, "compute", dev.Compute(), "driver", driver, "required_driver", "570 or newer")
continue
}
filtered = append(filtered, dev)
}
return filtered
}
func nvidiaDriverMajorFromDevices(devices []ml.DeviceInfo) int {
for _, dev := range devices {
if dev.Library == "CUDA" && dev.NVIDIADriverMajor > 0 {
return dev.NVIDIADriverMajor
}
}
return 0
}
+3 -14
View File
@@ -17,31 +17,20 @@ import (
// Included to drive logic for reducing Ollama-allocated overhead on L4T/Jetson devices.
var CudaTegra string = os.Getenv("JETSON_JETPACK")
// GetSystemInfo returns the last cached state of the GPUs on the system
// GetSystemInfo returns host memory information used by scheduling.
func GetSystemInfo() ml.SystemInfo {
logutil.Trace("performing CPU discovery")
logutil.Trace("performing system memory discovery")
startDiscovery := time.Now()
defer func() {
logutil.Trace("CPU discovery completed", "duration", time.Since(startDiscovery))
logutil.Trace("system memory discovery completed", "duration", time.Since(startDiscovery))
}()
memInfo, err := GetCPUMem()
if err != nil {
slog.Warn("error looking up system memory", "error", err)
}
var threadCount int
cpus := GetCPUDetails()
for _, c := range cpus {
threadCount += c.CoreCount - c.EfficiencyCoreCount
}
if threadCount == 0 {
// Fall back to Go's num CPU
threadCount = runtime.NumCPU()
}
return ml.SystemInfo{
ThreadCount: threadCount,
TotalMemory: memInfo.TotalMemory,
FreeMemory: memInfo.FreeMemory,
FreeSwap: memInfo.FreeSwap,
-25
View File
@@ -8,9 +8,6 @@ package discover
import "C"
import (
"log/slog"
"syscall"
"github.com/ollama/ollama/format"
)
@@ -26,28 +23,6 @@ func GetCPUMem() (memInfo, error) {
}, nil
}
func GetCPUDetails() []CPU {
query := "hw.perflevel0.physicalcpu"
perfCores, err := syscall.SysctlUint32(query)
if err != nil {
slog.Warn("failed to discover physical CPU details", "query", query, "error", err)
}
query = "hw.perflevel1.physicalcpu"
efficiencyCores, _ := syscall.SysctlUint32(query) // On x86 xeon this wont return data
// Determine thread count
query = "hw.logicalcpu"
logicalCores, _ := syscall.SysctlUint32(query)
return []CPU{
{
CoreCount: int(perfCores + efficiencyCores),
EfficiencyCoreCount: int(efficiencyCores),
ThreadCount: int(logicalCores),
},
}
}
func IsNUMA() bool {
// numa support in ggml is linux only
return false
+12 -4
View File
@@ -27,9 +27,17 @@ uint64_t getFreeMemory() {
return 0;
}
uint64_t free_memory = (uint64_t)vm_stat.free_count * pagesize;
free_memory += (uint64_t)vm_stat.speculative_count * pagesize;
free_memory += (uint64_t)vm_stat.inactive_count * pagesize;
uint64_t used = (uint64_t)vm_stat.active_count * pagesize
+ (uint64_t)vm_stat.inactive_count * pagesize
+ (uint64_t)vm_stat.speculative_count * pagesize
+ (uint64_t)vm_stat.wire_count * pagesize
+ (uint64_t)vm_stat.compressor_page_count * pagesize
- (uint64_t)vm_stat.purgeable_count * pagesize
- (uint64_t)vm_stat.external_page_count * pagesize;
return free_memory;
uint64_t total_memory = [NSProcessInfo processInfo].physicalMemory;
if (used >= total_memory) {
return 0;
}
return total_memory - used;
}
+513
View File
@@ -0,0 +1,513 @@
package discover
import (
"bufio"
"context"
"fmt"
"io"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"github.com/ollama/ollama/llm"
"github.com/ollama/ollama/logutil"
"github.com/ollama/ollama/ml"
)
// llamaServerDiscoveryWaitDelay bounds how long Wait can hang after we stop
// the short-lived discovery subprocess.
const llamaServerDiscoveryWaitDelay = 5 * time.Second
// llamaServerDiscoverDevices spawns llama-server briefly (without a model) to
// discover GPU devices and their capabilities. The server prints device info
// and system_info (including compiled CUDA architectures) on startup before
// any model load, then we kill it.
//
// Captured from combined stderr output:
//
// Device 0: NVIDIA GeForce RTX 4060 Ti, compute capability 8.9, VMM: yes, VRAM: 16379 MiB
// Device 0: AMD Radeon RX 6700 XT, gfx1031 (0x1031), VMM: no, Wave Size: 32, VRAM: 12272 MiB
//
// Captured from stdout device list:
//
// CUDA0: NVIDIA GeForce RTX 4060 Ti (16379 MiB, 14900 MiB free)
// Metal: Apple M3 Max (98304 MiB, 98303 MiB free)
func llamaServerDiscoverDevices(ctx context.Context, libDirs []string, extraEnvs map[string]string) ([]ml.DeviceInfo, *llm.StatusWriter, error) {
status := llm.NewStatusWriter(llamaServerDiscoveryOutput(ctx))
llamaServer, err := llm.FindLlamaServer()
if err != nil {
slog.Debug("llama-server not available for device discovery", "error", err)
return nil, status, err
}
start := time.Now()
defer func() {
slog.Debug("llama-server device discovery took", "duration", time.Since(start), "libDirs", libDirs)
}()
// Use a random port to avoid conflicts. The server may start listening
// before it emits system_info, but we stop it as soon as we have the GPU
// discovery output we need.
port := 49152 + time.Now().UnixNano()%16383
cmd := exec.CommandContext(ctx, llamaServer,
"--port", strconv.FormatInt(port, 10),
"--host", "127.0.0.1",
"--no-webui",
"--offline",
"--verbose",
)
cmd.WaitDelay = llamaServerDiscoveryWaitDelay
cmd.Env = os.Environ()
llm.SetupLlamaServerCommandEnv(cmd, llamaServer, libDirs, extraEnvs)
logutil.Trace("running llama-server for discovery", "cmd", cmd.Path, "libDirs", libDirs)
// Capture stderr (device info + system_info) via pipe so we can
// read it line-by-line and kill the server as soon as we have what we need.
stderrPipe, err := cmd.StderrPipe()
if err != nil {
slog.Debug("llama-server discovery: failed to create stderr pipe", "error", err)
return nil, status, err
}
// Forward stdout through the same status writer so trace logging captures
// all llama-server discovery output.
cmd.Stdout = status
if err := cmd.Start(); err != nil {
slog.Debug("llama-server discovery: failed to start", "error", err)
return nil, status, err
}
// Read stderr until we see system_info or timeout
var stderrLines []string
gotSystemInfo := false
done := make(chan struct{})
go func() {
scanner := bufio.NewScanner(stderrPipe)
for scanner.Scan() {
line := scanner.Text()
_, _ = status.Write([]byte(line + "\n"))
stderrLines = append(stderrLines, line)
if strings.Contains(line, "system_info:") {
gotSystemInfo = true
break
}
}
close(done)
}()
select {
case <-done:
case <-ctx.Done():
}
// Kill the server - we have what we need, or timed out.
stoppedForDiscovery := false
if cmd.Process != nil {
stoppedForDiscovery = cmd.Process.Kill() == nil
}
waitErr := cmd.Wait()
if waitErr != nil {
exit := llm.ExitStatusFromError(waitErr)
if stoppedForDiscovery {
slog.Debug("llama-server discovery: stopped subprocess after collecting GPU info", "exit", exit, "libDirs", libDirs)
}
if !stoppedForDiscovery {
slog.Debug("llama-server discovery: server startup exited", "error", waitErr, "exit", exit, "libDirs", libDirs)
}
}
<-done
if ctx.Err() != nil {
slog.Warn("llama-server discovery: timed out waiting for server startup", "error", ctx.Err(), "libDirs", libDirs, "lines_captured", len(stderrLines))
return nil, status, ctx.Err()
}
if !gotSystemInfo {
slog.Warn("llama-server discovery: system_info line not found in output - "+
"CUDA architecture filtering will be disabled. If GPU inference fails, "+
"this may indicate an incompatible llama-server version.",
"libDirs", libDirs, "lines_captured", len(stderrLines))
}
// Also run --list-devices to get the stdout device list with free memory
// (the brief server startup doesn't print that)
cmd2 := exec.CommandContext(ctx, llamaServer, "--list-devices", "--offline", "--verbose")
cmd2.WaitDelay = llamaServerDiscoveryWaitDelay
cmd2.Env = cmd.Env // reuse same environment
listOutput, err := cmd2.CombinedOutput()
_, _ = status.Write(listOutput)
if err != nil {
exit := llm.ExitStatusFromError(err)
slog.Debug("llama-server --list-devices failed", "error", err, "exit", exit)
if exit.Known() {
return nil, status, fmt.Errorf("llama-server --list-devices failed: %s", exit)
}
return nil, status, fmt.Errorf("llama-server --list-devices failed: %w", err)
}
nativeDevices, nativeStderr, nativeErr := discoverNativeDevices(ctx, llamaServer, libDirs, extraEnvs)
_, _ = status.Write([]byte(nativeStderr))
if nativeErr != nil {
logNativeProbeFailure(nativeErr, nativeStderr, libDirs)
}
combined := string(listOutput) + "\n" + strings.Join(stderrLines, "\n") + "\n" + nativeStderr
return parseLlamaServerDevicesWithNative(combined, libDirs, nativeDevices), status, nil
}
func llamaServerDiscoveryOutput(ctx context.Context) io.Writer {
if slog.Default().Enabled(ctx, logutil.LevelTrace) {
return os.Stderr
}
return io.Discard
}
// deviceLineRegex matches stdout lines like:
//
// CUDA0: NVIDIA GeForce RTX 4060 Ti (16379 MiB, 14900 MiB free)
// Metal: Apple M3 Max (98304 MiB, 98303 MiB free)
var deviceLineRegex = regexp.MustCompile(
`^\s+(.+?):\s+(.+?)\s+\((\d+)\s+MiB,\s+(\d+)\s+MiB\s+free\)`,
)
// cudaCCRegex matches CUDA stderr lines like:
//
// Device 0: NVIDIA GeForce GTX 1060 6GB, compute capability 6.1, VMM: yes, VRAM: 6063 MiB
var cudaCCRegex = regexp.MustCompile(
`Device\s+(\d+):.*compute capability\s+(\d+)\.(\d+)`,
)
// cudaArchsRegex matches the CUDA architecture list from system_info like:
//
// CUDA : ARCHS = 750,800,860,890,900,1000,1030,1100,1200,1210
var cudaArchsRegex = regexp.MustCompile(
`CUDA\s*:\s*ARCHS\s*=\s*([\d,]+)`,
)
var (
cudaRuntimeSORegex = regexp.MustCompile(`^libcudart\.so\.(\d+)(?:\.(\d+))?`)
cudaRuntimeDLLRegex = regexp.MustCompile(`^cudart64_(\d{2})(\d)\.dll$`)
cudaRuntimeDirRegex = regexp.MustCompile(`^cuda_v(\d+)$`)
)
// parseLlamaServerDevices parses the combined output of llama-server discovery.
// It extracts device info, ROCm gfx targets, CUDA compute capabilities, and
// CUDA compiled architecture lists.
func parseLlamaServerDevices(output string, libDirs []string) []ml.DeviceInfo {
return parseLlamaServerDevicesWithNative(output, libDirs, nil)
}
func parseLlamaServerDevicesWithNative(output string, libDirs []string, nativeDevices []nativeProbeDevice) []ml.DeviceInfo {
// Extract per-device metadata from stderr
gfxByIndex := parseROCmGFXTargets(output)
rocmGFXOverride := hsaOverrideGFXTarget()
integratedByIndex := parseVulkanUMA(output)
ccByIndex := make(map[int]cudaComputeCapability)
var cudaArchs []string // compiled architectures for this variant
nativeByIndex := nativeProbeByLibraryIndex(nativeDevices)
for idx, dev := range nativeByIndex["ROCm"] {
if rocmGFXOverride != "" {
gfxByIndex[idx] = rocmGFXOverride
} else if dev.GFXTarget != "" {
gfxByIndex[idx] = dev.GFXTarget
}
}
scanner := bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
line := scanner.Text()
if matches := cudaCCRegex.FindStringSubmatch(line); matches != nil {
idx, _ := strconv.Atoi(matches[1])
major, _ := strconv.Atoi(matches[2])
minor, _ := strconv.Atoi(matches[3])
ccByIndex[idx] = cudaComputeCapability{
major: major,
minor: minor,
arch: fmt.Sprintf("%d%d0", major, minor),
}
}
if matches := cudaArchsRegex.FindStringSubmatch(line); matches != nil {
cudaArchs = strings.Split(matches[1], ",")
}
}
if cudaDevices := nativeByIndex["CUDA"]; len(cudaDevices) > 0 {
for idx, dev := range cudaDevices {
if dev.ComputeMajor <= 0 {
continue
}
ccByIndex[idx] = cudaComputeCapability{
major: dev.ComputeMajor,
minor: dev.ComputeMinor,
arch: fmt.Sprintf("%d%d0", dev.ComputeMajor, dev.ComputeMinor),
}
}
}
// Validate CUDA devices against compiled architectures
cudaArchSet := make(map[string]bool, len(cudaArchs))
for _, arch := range cudaArchs {
cudaArchSet[strings.TrimSpace(arch)] = true
}
cudaRuntimeMajor, cudaRuntimeMinor, hasCUDARuntime := cudaRuntimeVersion(libDirs)
// Parse stdout device lines
var devices []ml.DeviceInfo
deviceIndex := 0
scanner = bufio.NewScanner(strings.NewReader(output))
for scanner.Scan() {
matches := deviceLineRegex.FindStringSubmatch(scanner.Text())
if matches == nil {
continue
}
name := matches[1]
description := matches[2]
totalMiB, _ := strconv.ParseUint(matches[3], 10, 64)
freeMiB, _ := strconv.ParseUint(matches[4], 10, 64)
library := inferLibrary(name, description)
// Skip pseudo-devices like BLAS/Accelerate that report zero memory.
// These are CPU math libraries, not real GPUs — they shouldn't appear
// as inference compute devices or inflate the scheduler's GPU count.
if totalMiB == 0 {
slog.Debug("skipping pseudo-device with zero memory", "name", name, "description", description)
deviceIndex++
continue
}
// For CUDA devices, check if this variant supports the device's CC
if library == "CUDA" {
cc, ok := ccByIndex[deviceIndex]
if ok && len(cudaArchSet) > 0 {
if !cudaArchSet[cc.arch] {
slog.Info("skipping CUDA device — compute capability not in compiled architectures",
"device", description, "cc", cc.arch, "archs", cudaArchs,
"libDirs", libDirs)
deviceIndex++
continue
}
} else if !ok {
slog.Warn("llama-server discovery: could not determine compute capability for CUDA device — "+
"architecture filtering disabled for this device. If inference crashes, "+
"check that the CUDA backend supports this GPU.",
"device", description, "libDirs", libDirs)
} else if len(cudaArchSet) == 0 {
slog.Warn("llama-server discovery: could not determine compiled CUDA architectures — "+
"architecture filtering disabled. If inference crashes on older GPUs, "+
"check llama-server system_info output for ARCHS.",
"device", description, "libDirs", libDirs)
}
}
nativeDevice, hasNativeDevice := nativeByIndex[library][deviceIndex]
totalBytes := totalMiB * 1024 * 1024
if hasNativeDevice && !nativeProbeMatchesLlamaServerDevice(library, description, totalBytes, nativeDevice) {
hasNativeDevice = false
}
computeMajor, computeMinor := computeVersion(library, deviceIndex, gfxByIndex, ccByIndex)
dev := ml.DeviceInfo{
DeviceID: ml.DeviceID{
ID: strconv.Itoa(deviceIndex),
Library: library,
},
Name: name,
Description: description,
TotalMemory: totalBytes,
FreeMemory: freeMiB * 1024 * 1024,
ComputeMajor: computeMajor,
ComputeMinor: computeMinor,
LibraryPath: libDirs,
GFXTarget: gfxByIndex[deviceIndex],
Integrated: isIntegratedLlamaServerDevice(library, deviceIndex, integratedByIndex),
}
if hasNativeDevice {
if nativeDevice.DeviceID != "" {
dev.PCIID = nativeDevice.DeviceID
}
if nativeDevice.IntegratedKnown {
dev.Integrated = nativeDevice.Integrated
} else {
dev.Integrated = dev.Integrated || nativeDevice.Integrated
}
if dev.ComputeMajor == 0 && nativeDevice.ComputeMajor > 0 {
dev.ComputeMajor = nativeDevice.ComputeMajor
dev.ComputeMinor = nativeDevice.ComputeMinor
}
if nativeDevice.CUDADriverMajor > 0 {
dev.DriverMajor = nativeDevice.CUDADriverMajor
dev.DriverMinor = nativeDevice.CUDADriverMinor
}
if nativeDevice.NVIDIADriverMajor > 0 {
dev.NVIDIADriverMajor = nativeDevice.NVIDIADriverMajor
}
setROCmGFXTarget(&dev, nativeDevice.GFXTarget)
}
setROCmGFXTarget(&dev, rocmGFXOverride)
if library == "CUDA" && dev.DriverMajor == 0 && hasCUDARuntime {
dev.DriverMajor = cudaRuntimeMajor
dev.DriverMinor = cudaRuntimeMinor
}
devices = append(devices, dev)
deviceIndex++
}
return refineLlamaServerDevices(devices, libDirs)
}
func nativeProbeMatchesLlamaServerDevice(library, description string, totalBytes uint64, nativeDevice nativeProbeDevice) bool {
if library != "Vulkan" {
return true
}
nativeDescription := nativeDevice.Description
if nativeDescription == "" {
nativeDescription = nativeDevice.Name
}
if nativeDescription == "" || !ml.SimilarDeviceDescription(description, nativeDescription) {
slog.Debug("skipping Vulkan native metadata with mismatched device name",
"llama_server_name", description,
"native_name", nativeDescription)
return false
}
if nativeDevice.TotalMemory != 0 && !ml.SimilarDeviceMemory(totalBytes, nativeDevice.TotalMemory) {
slog.Debug("skipping Vulkan native metadata with mismatched memory",
"llama_server_name", description,
"llama_server_total", totalBytes,
"native_total", nativeDevice.TotalMemory)
return false
}
return true
}
func cudaRuntimeVersion(libDirs []string) (int, int, bool) {
bestMajor, bestMinor := -1, -1
update := func(major, minor int) {
if major > bestMajor || (major == bestMajor && minor > bestMinor) {
bestMajor, bestMinor = major, minor
}
}
for _, dir := range libDirs {
for _, entry := range readDirNames(dir) {
if matches := cudaRuntimeSORegex.FindStringSubmatch(entry); matches != nil {
major, _ := strconv.Atoi(matches[1])
minor := 0
if matches[2] != "" {
minor, _ = strconv.Atoi(matches[2])
}
update(major, minor)
}
if matches := cudaRuntimeDLLRegex.FindStringSubmatch(entry); matches != nil {
major, _ := strconv.Atoi(matches[1])
minor, _ := strconv.Atoi(matches[2])
update(major, minor)
}
}
if matches := cudaRuntimeDirRegex.FindStringSubmatch(filepath.Base(dir)); matches != nil {
major, _ := strconv.Atoi(matches[1])
update(major, 0)
}
}
if bestMajor < 0 {
return 0, 0, false
}
return bestMajor, bestMinor, true
}
func readDirNames(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
return names
}
type cudaComputeCapability struct {
major int
minor int
arch string
}
func computeVersion(library string, deviceIndex int, gfxByIndex map[int]string, ccByIndex map[int]cudaComputeCapability) (int, int) {
switch library {
case "CUDA":
if cc, ok := ccByIndex[deviceIndex]; ok {
return cc.major, cc.minor
}
case "ROCm":
return parseGFXTarget(gfxByIndex[deviceIndex])
}
return 0, 0
}
// inferLibrary determines the GPU library type from the llama-server device name and description.
func inferLibrary(name, description string) string {
combined := strings.ToLower(name + " " + description)
switch {
case strings.Contains(combined, "cuda"):
return "CUDA"
case strings.Contains(combined, "rocm") || strings.Contains(combined, "hip"):
return "ROCm"
case strings.Contains(combined, "metal") || strings.Contains(combined, "apple"):
return "Metal"
case strings.Contains(combined, "vulkan"):
return "Vulkan"
default:
return description
}
}
func isIntegratedLlamaServerDevice(library string, deviceIndex int, integratedByIndex map[int]bool) bool {
if library == "Vulkan" && integratedByIndex[deviceIndex] {
return true
}
// llama-server discovery does not expose a stable backend device-type field,
// so we only infer "integrated" here for cases where the contract is strong:
// explicit Vulkan UMA metadata, or the single Apple Silicon Metal device.
//
// Other backends stay unclassified unless discovery provides a stronger
// signal. That keeps scheduling conservative instead of guessing from
// device names or backend-specific heuristics.
return library == "Metal" && runtime.GOOS == "darwin" && runtime.GOARCH == "arm64"
}
func llamaServerBootstrapDevicesWithStatus(ctx context.Context, ollamaLibDirs []string, extraEnvs map[string]string) ([]ml.DeviceInfo, *llm.StatusWriter, error) {
devices, status, err := llamaServerDiscoverDevices(ctx, ollamaLibDirs, extraEnvs)
if err != nil {
return devices, status, err
}
hasROCm := false
for _, d := range devices {
if d.Library == "ROCm" {
hasROCm = true
break
}
}
if !hasROCm {
return devices, status, nil
}
return filterUnsupportedROCmDevices(devices, ollamaLibDirs), status, nil
}
// Ensure stderrPipe is fully consumed to avoid blocking
var _ io.Reader
+515
View File
@@ -0,0 +1,515 @@
package discover
import (
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/ollama/ollama/logutil"
"github.com/ollama/ollama/ml"
)
func TestLlamaServerDiscovery(t *testing.T) {
originalProbe := probeLlamaServerVulkanDevices
probeLlamaServerVulkanDevices = func(_ []string) ([]vulkanPhysicalDevice, error) {
return nil, errWindowsVulkanProbeUnsupported
}
t.Cleanup(func() {
probeLlamaServerVulkanDevices = originalProbe
})
t.Run("output only trace", func(t *testing.T) {
original := slog.Default()
t.Cleanup(func() {
slog.SetDefault(original)
})
slog.SetDefault(logutil.NewLogger(io.Discard, slog.LevelDebug))
if got := llamaServerDiscoveryOutput(t.Context()); got != io.Discard {
t.Fatal("debug logging should discard raw llama-server discovery output")
}
slog.SetDefault(logutil.NewLogger(io.Discard, logutil.LevelTrace))
if got := llamaServerDiscoveryOutput(t.Context()); got == io.Discard {
t.Fatal("trace logging should emit raw llama-server discovery output")
}
})
t.Run("parse devices", func(t *testing.T) {
type wantDevice struct {
name string
library string
totalMiB uint64
compute string
driver string
gfxTarget string
checkIntegrated bool
integrated bool
}
tests := []struct {
name string
output string
libDirs []string
want []wantDevice
}{
{
name: "NVIDIA CUDA",
output: `load_backend: loaded CUDA backend from /lib/ollama/cuda_v12/libggml-cuda.so
Available devices:
NVIDIA GeForce RTX 4090: NVIDIA CUDA (24564 MiB, 23592 MiB free)
`,
libDirs: []string{"/lib/ollama", "/lib/ollama/cuda_v12"},
want: []wantDevice{{
name: "NVIDIA GeForce RTX 4090",
library: "CUDA",
totalMiB: 24564,
driver: "12.0",
}},
},
{
name: "Metal",
output: `Available devices:
Metal: Apple M3 Max (98304 MiB, 98303 MiB free)
`,
want: []wantDevice{{
name: "Metal",
library: "Metal",
totalMiB: 98304,
}},
},
{
name: "ROCm with gfx target",
output: ` Device 0: AMD Radeon RX 6700 XT, gfx1031 (0x1031), VMM: no, Wave Size: 32, VRAM: 12272 MiB
Available devices:
ROCm0: AMD Radeon RX 6700 XT (12272 MiB, 12248 MiB free)
`,
libDirs: []string{"/lib/ollama", "/lib/ollama/rocm_v7_2"},
want: []wantDevice{{
name: "ROCm0",
library: "ROCm",
totalMiB: 12272,
compute: "gfx1031",
gfxTarget: "gfx1031",
}},
},
{
name: "multi GPU",
output: `Available devices:
CUDA0: NVIDIA GeForce RTX 4090 (24564 MiB, 23592 MiB free)
CUDA1: NVIDIA GeForce RTX 3060 (12288 MiB, 11500 MiB free)
`,
libDirs: []string{"/lib/ollama", "/lib/ollama/cuda_v12"},
want: []wantDevice{
{name: "CUDA0", library: "CUDA", totalMiB: 24564},
{name: "CUDA1", library: "CUDA", totalMiB: 12288},
},
},
{
name: "Vulkan UMA",
output: `ggml_vulkan: 0 = Intel(R) Graphics (Intel open-source Mesa driver) | uma: 1 | fp16: 1 | bf16: 0 | warp size: 32 | shared memory: 65536 | int dot: 1 | matrix cores: none
Available devices:
Vulkan0: Intel(R) Graphics (16384 MiB, 12288 MiB free)
`,
libDirs: []string{"/lib/ollama", "/lib/ollama/vulkan"},
want: []wantDevice{{
name: "Vulkan0",
library: "Vulkan",
totalMiB: 16384,
checkIntegrated: true,
integrated: true,
}},
},
{
name: "Vulkan without UMA metadata",
output: `Available devices:
Vulkan0: AMD Radeon(TM) Graphics (32768 MiB, 31000 MiB free)
`,
libDirs: []string{"/lib/ollama", "/lib/ollama/vulkan"},
want: []wantDevice{{
name: "Vulkan0",
library: "Vulkan",
totalMiB: 32768,
checkIntegrated: true,
}},
},
{
name: "CUDA device filtered by compiled archs",
output: `ggml_cuda_init: found 1 CUDA devices (Total VRAM: 6063 MiB):
Device 0: NVIDIA GeForce GTX 1060 6GB, compute capability 6.1, VMM: yes, VRAM: 6063 MiB
load_backend: loaded CUDA backend from /lib/ollama/cuda_v13/libggml-cuda.so
system_info: n_threads = 4 | CUDA : ARCHS = 750,800,860,890,900,1000,1030,1100,1200,1210 |
Available devices:
CUDA0: NVIDIA GeForce GTX 1060 6GB (6063 MiB, 5900 MiB free)
`,
libDirs: []string{"/lib/ollama", "/lib/ollama/cuda_v13"},
},
{
name: "CUDA device kept by compiled archs",
output: `ggml_cuda_init: found 1 CUDA devices (Total VRAM: 16379 MiB):
Device 0: NVIDIA GeForce RTX 4060 Ti, compute capability 8.9, VMM: yes, VRAM: 16379 MiB
system_info: n_threads = 16 | CUDA : ARCHS = 750,800,860,890,900,1000,1030,1100,1200,1210 |
Available devices:
CUDA0: NVIDIA GeForce RTX 4060 Ti (16379 MiB, 14900 MiB free)
`,
want: []wantDevice{{
name: "CUDA0",
library: "CUDA",
totalMiB: 16379,
compute: "8.9",
}},
},
{
name: "CUDA without compiled archs fails open",
output: `ggml_cuda_init: found 1 CUDA devices (Total VRAM: 6063 MiB):
Device 0: NVIDIA GeForce GTX 1060 6GB, compute capability 6.1, VMM: yes, VRAM: 6063 MiB
Available devices:
CUDA0: NVIDIA GeForce GTX 1060 6GB (6063 MiB, 5900 MiB free)
`,
want: []wantDevice{{
name: "CUDA0",
library: "CUDA",
totalMiB: 6063,
compute: "6.1",
}},
},
{
name: "CUDA without compute capability fails open",
output: `system_info: n_threads = 4 | CUDA : ARCHS = 750,800 |
Available devices:
CUDA0: Some Future GPU (8192 MiB, 8000 MiB free)
`,
want: []wantDevice{{
name: "CUDA0",
library: "CUDA",
totalMiB: 8192,
}},
},
{
name: "CUDA mixed arch support",
output: `ggml_cuda_init: found 2 CUDA devices:
Device 0: NVIDIA GeForce GTX 1060, compute capability 6.1, VMM: yes, VRAM: 6063 MiB
Device 1: NVIDIA GeForce RTX 4060 Ti, compute capability 8.9, VMM: yes, VRAM: 16379 MiB
system_info: n_threads = 8 | CUDA : ARCHS = 750,800,860,890 |
Available devices:
CUDA0: NVIDIA GeForce GTX 1060 (6063 MiB, 5900 MiB free)
CUDA1: NVIDIA GeForce RTX 4060 Ti (16379 MiB, 14900 MiB free)
`,
want: []wantDevice{{
name: "CUDA1",
library: "CUDA",
totalMiB: 16379,
compute: "8.9",
}},
},
{
name: "ROCm gfx target with xnack suffix",
output: `ggml_cuda_init: found 2 ROCm devices (Total VRAM: 32736 MiB):
Device 0: AMD Radeon RX 6800, gfx1030 (0x1030), VMM: no, Wave Size: 32, VRAM: 16368 MiB
Device 1: AMD Radeon Pro VII, gfx906:sramecc+:xnack- (0x906), VMM: no, Wave Size: 64, VRAM: 16368 MiB
Available devices:
ROCm0: AMD Radeon RX 6800 (16368 MiB, 16342 MiB free)
ROCm1: AMD Radeon Pro VII (16368 MiB, 16348 MiB free)
`,
want: []wantDevice{
{name: "ROCm0", library: "ROCm", totalMiB: 16368, compute: "gfx1030", gfxTarget: "gfx1030"},
{name: "ROCm1", library: "ROCm", totalMiB: 16368, compute: "gfx906", gfxTarget: "gfx906"},
},
},
{
name: "unknown library",
output: `Available devices:
Future0: Mystery Accelerator (8192 MiB, 8000 MiB free)
`,
want: []wantDevice{{
name: "Future0",
library: "Mystery Accelerator",
totalMiB: 8192,
}},
},
{
name: "no devices",
output: "Available devices:\n",
},
{
name: "empty output",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.libDirs == nil {
tt.libDirs = []string{"/lib/ollama"}
}
devices := parseLlamaServerDevices(tt.output, tt.libDirs)
if len(devices) != len(tt.want) {
t.Fatalf("got %d devices, want %d", len(devices), len(tt.want))
}
for i, want := range tt.want {
got := devices[i]
if want.name != "" && got.Name != want.name {
t.Errorf("device %d name = %q, want %q", i, got.Name, want.name)
}
if want.library != "" && got.Library != want.library {
t.Errorf("device %d library = %q, want %q", i, got.Library, want.library)
}
if want.totalMiB > 0 && got.TotalMemory != want.totalMiB*1024*1024 {
t.Errorf("device %d total memory = %d, want %d MiB", i, got.TotalMemory, want.totalMiB)
}
if want.compute != "" && got.Compute() != want.compute {
t.Errorf("device %d compute = %q, want %q", i, got.Compute(), want.compute)
}
if want.driver != "" && got.Driver() != want.driver {
t.Errorf("device %d driver = %q, want %q", i, got.Driver(), want.driver)
}
if want.gfxTarget != "" && got.GFXTarget != want.gfxTarget {
t.Errorf("device %d gfx target = %q, want %q", i, got.GFXTarget, want.gfxTarget)
}
if want.checkIntegrated && got.Integrated != want.integrated {
t.Errorf("device %d integrated = %v, want %v", i, got.Integrated, want.integrated)
}
}
})
}
})
t.Run("parse fixtures", func(t *testing.T) {
type wantDevice struct {
name string
library string
totalMiB uint64
compute string
gfxTarget string
integrated bool
}
tests := []struct {
name string
output string
libDirs []string
want []wantDevice
wantSkip string
}{
{
name: "cuda mixed archs filters unsupported device",
output: `ggml_cuda_init: found 2 CUDA devices:
Device 0: NVIDIA GeForce GTX 1060, compute capability 6.1, VMM: yes, VRAM: 6063 MiB
Device 1: NVIDIA GeForce RTX 4060 Ti, compute capability 8.9, VMM: yes, VRAM: 16379 MiB
system_info: n_threads = 8 | CUDA : ARCHS = 750,800,860,890 |
Available devices:
CUDA0: NVIDIA GeForce GTX 1060 (6063 MiB, 5900 MiB free)
CUDA1: NVIDIA GeForce RTX 4060 Ti (16379 MiB, 14900 MiB free)
`,
want: []wantDevice{{
name: "CUDA1",
library: "CUDA",
totalMiB: 16379,
compute: "8.9",
}},
},
{
name: "rocm gfx targets preserve suffix-free compute",
output: `ggml_cuda_init: found 2 ROCm devices (Total VRAM: 32736 MiB):
Device 0: AMD Radeon RX 6800, gfx1030 (0x1030), VMM: no, Wave Size: 32, VRAM: 16368 MiB
Device 1: AMD Radeon Pro VII, gfx906:sramecc+:xnack- (0x906), VMM: no, Wave Size: 64, VRAM: 16368 MiB
Available devices:
ROCm0: AMD Radeon RX 6800 (16368 MiB, 16342 MiB free)
ROCm1: AMD Radeon Pro VII (16368 MiB, 16348 MiB free)
`,
want: []wantDevice{
{name: "ROCm0", library: "ROCm", totalMiB: 16368, compute: "gfx1030", gfxTarget: "gfx1030"},
{name: "ROCm1", library: "ROCm", totalMiB: 16368, compute: "gfx906", gfxTarget: "gfx906"},
},
},
{
name: "vulkan uma marks integrated",
output: `ggml_vulkan: 0 = Intel(R) Graphics (Intel open-source Mesa driver) | uma: 1 | fp16: 1 | bf16: 0 | warp size: 32 | shared memory: 65536 | int dot: 1 | matrix cores: none
Available devices:
Vulkan0: Intel(R) Graphics (16384 MiB, 12288 MiB free)
`,
want: []wantDevice{{
name: "Vulkan0",
library: "Vulkan",
totalMiB: 16384,
integrated: true,
}},
},
{
name: "windows vulkan without uma stays unclassified",
output: `load_backend: loaded Vulkan backend from C:\ollama\lib\ollama\vulkan\ggml-vulkan.dll
Available devices:
Vulkan0: AMD Radeon(TM) Graphics (32768 MiB, 31000 MiB free)
Vulkan1: AMD Radeon RX 7900 XTX (24564 MiB, 23000 MiB free)
`,
want: []wantDevice{
{name: "Vulkan0", library: "Vulkan", totalMiB: 32768},
{name: "Vulkan1", library: "Vulkan", totalMiB: 24564},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
libDirs := tt.libDirs
if libDirs == nil {
libDirs = []string{"/lib/ollama"}
}
got := parseLlamaServerDevices(tt.output, libDirs)
if len(got) != len(tt.want) {
t.Fatalf("got %d devices, want %d", len(got), len(tt.want))
}
for i, want := range tt.want {
if got[i].Name != want.name {
t.Fatalf("device %d name = %q, want %q", i, got[i].Name, want.name)
}
if got[i].Library != want.library {
t.Fatalf("device %d library = %q, want %q", i, got[i].Library, want.library)
}
if got[i].TotalMemory != want.totalMiB*1024*1024 {
t.Fatalf("device %d total memory = %d, want %d MiB", i, got[i].TotalMemory, want.totalMiB)
}
if want.compute != "" && got[i].Compute() != want.compute {
t.Fatalf("device %d compute = %q, want %q", i, got[i].Compute(), want.compute)
}
if want.gfxTarget != "" && got[i].GFXTarget != want.gfxTarget {
t.Fatalf("device %d gfx target = %q, want %q", i, got[i].GFXTarget, want.gfxTarget)
}
if got[i].Integrated != want.integrated {
t.Fatalf("device %d integrated = %v, want %v", i, got[i].Integrated, want.integrated)
}
}
})
}
})
t.Run("skips mismatched Vulkan native metadata", func(t *testing.T) {
output := `Available devices:
Vulkan0: Intel(R) UHD Graphics 770 (32768 MiB, 31000 MiB free)
`
nativeDevices := []nativeProbeDevice{{
Library: "Vulkan",
Index: 0,
IndexMatchesBackend: true,
Description: "NVIDIA GeForce RTX 4060 Ti",
DeviceID: "0000:05:00.0",
IntegratedKnown: true,
TotalMemory: 16107 * 1024 * 1024,
}}
devices := parseLlamaServerDevicesWithNative(output, []string{"/lib/ollama", "/lib/ollama/vulkan"}, nativeDevices)
if len(devices) != 1 {
t.Fatalf("got %d devices, want 1", len(devices))
}
if devices[0].PCIID != "" {
t.Fatalf("PCIID = %q, want empty", devices[0].PCIID)
}
if devices[0].Integrated {
t.Fatal("Integrated = true, want false")
}
})
t.Run("cuda runtime version", func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "libcudart.so.12.8.90"), nil, 0o644); err != nil {
t.Fatal(err)
}
major, minor, ok := cudaRuntimeVersion([]string{dir})
if !ok || major != 12 || minor != 8 {
t.Fatalf("cudaRuntimeVersion = %d.%d, %v, want 12.8, true", major, minor, ok)
}
major, minor, ok = cudaRuntimeVersion([]string{filepath.Join(t.TempDir(), "cuda_v13")})
if !ok || major != 13 || minor != 0 {
t.Fatalf("cudaRuntimeVersion fallback = %d.%d, %v, want 13.0, true", major, minor, ok)
}
})
t.Run("refine windows vulkan devices", func(t *testing.T) {
makeDevices := func() []ml.DeviceInfo {
return []ml.DeviceInfo{
{DeviceID: ml.DeviceID{ID: "0", Library: "Vulkan"}, Description: "AMD Radeon(TM) Graphics"},
{DeviceID: ml.DeviceID{ID: "1", Library: "Vulkan"}, Description: "AMD Radeon RX 7900 XTX"},
{DeviceID: ml.DeviceID{ID: "0", Library: "CUDA"}, Description: "NVIDIA GeForce RTX 4090"},
}
}
tests := []struct {
name string
devices []ml.DeviceInfo
probed []vulkanPhysicalDevice
want []bool
applied bool
}{
{
name: "fills missing integrated bit",
probed: []vulkanPhysicalDevice{
{Name: "AMD Radeon(TM) Graphics", Integrated: true},
{Name: "AMD Radeon RX 7900 XTX", Integrated: false},
},
want: []bool{true, false, false},
applied: true,
},
{
name: "matches names when order differs",
probed: []vulkanPhysicalDevice{
{Name: "AMD Radeon RX 7900 XTX", Integrated: false},
{Name: "AMD Radeon(TM) Graphics", Integrated: true},
},
want: []bool{true, false, false},
applied: true,
},
{
name: "skips when names do not line up",
probed: []vulkanPhysicalDevice{
{Name: "Wrong GPU", Integrated: true},
{Name: "AMD Radeon RX 7900 XTX", Integrated: false},
},
want: []bool{false, false, false},
},
{
name: "skips when counts do not line up",
probed: []vulkanPhysicalDevice{{Name: "AMD Radeon(TM) Graphics", Integrated: true}},
want: []bool{false, false, false},
},
{
name: "overwrites stale classification",
devices: []ml.DeviceInfo{
{DeviceID: ml.DeviceID{ID: "0", Library: "Vulkan"}, Description: "AMD Radeon(TM) Graphics", Integrated: true},
{DeviceID: ml.DeviceID{ID: "1", Library: "Vulkan"}, Description: "AMD Radeon RX 7900 XTX"},
},
probed: []vulkanPhysicalDevice{
{Name: "AMD Radeon(TM) Graphics", Integrated: false},
{Name: "AMD Radeon RX 7900 XTX", Integrated: false},
},
want: []bool{false, false},
applied: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
devices := tt.devices
if devices == nil {
devices = makeDevices()
}
applied := applyWindowsVulkanRefinement(devices, tt.probed)
if applied != tt.applied {
t.Fatalf("applied = %v, want %v", applied, tt.applied)
}
got := devices
if len(got) != len(tt.want) {
t.Fatalf("got %d devices, want %d", len(got), len(tt.want))
}
for i, want := range tt.want {
if got[i].Integrated != want {
t.Fatalf("device %d integrated = %v, want %v", i, got[i].Integrated, want)
}
}
})
}
})
}
+263
View File
@@ -0,0 +1,263 @@
package discover
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/ollama/ollama/llm"
"github.com/ollama/ollama/ml"
)
// Native GPU discovery runs in a short-lived Ollama subprocess so loading GGML
// and driver libraries cannot crash the main server process. The subprocess
// keeps stdout reserved for JSON and lets GGML's default logger write to
// stderr; the parent captures that stderr for trace/debug diagnostics.
const nativeProbeTimeout = 15 * time.Second
type nativeProbeDevice struct {
Library string `json:"library"`
Index int `json:"index"`
// IndexMatchesBackend means Index is in the same visible-device order that
// llama-server reports, so it is safe to correlate when PCI ID is missing.
IndexMatchesBackend bool `json:"index_matches_backend,omitempty"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
DeviceID string `json:"device_id,omitempty"`
Integrated bool `json:"integrated,omitempty"`
IntegratedKnown bool `json:"integrated_known"`
TotalMemory uint64 `json:"total_memory,omitempty"`
FreeMemory uint64 `json:"free_memory,omitempty"`
ComputeMajor int `json:"compute_major,omitempty"`
ComputeMinor int `json:"compute_minor,omitempty"`
CUDADriverMajor int `json:"cuda_driver_major,omitempty"`
CUDADriverMinor int `json:"cuda_driver_minor,omitempty"`
NVIDIADriverMajor int `json:"nvidia_driver_major,omitempty"`
GFXTarget string `json:"gfx_target,omitempty"`
}
type nativeProbeResult struct {
Devices []nativeProbeDevice `json:"devices"`
}
type ggmlBackendDevCaps struct {
Async uint8
HostBuffer uint8
BufferFromHostPtr uint8
Events uint8
}
type ggmlBackendDevProps struct {
Name uintptr
Description uintptr
MemoryFree uintptr
MemoryTotal uintptr
Type int32
_ [4]byte
DeviceID uintptr
Caps ggmlBackendDevCaps
_ [4]byte
}
func discoverNativeDevices(ctx context.Context, llamaServer string, libDirs []string, extraEnvs map[string]string) ([]nativeProbeDevice, string, error) {
if runtime.GOOS != "linux" && runtime.GOOS != "windows" {
return nil, "", nil
}
exe, err := os.Executable()
if err != nil {
return nil, "", err
}
ctx, cancel := context.WithTimeout(ctx, nativeProbeTimeout)
defer cancel()
args := []string{"gpu-discover"}
for _, dir := range libDirs {
args = append(args, "--lib-dir", dir)
}
cmd := exec.CommandContext(ctx, exe, args...)
cmd.WaitDelay = llamaServerDiscoveryWaitDelay
llm.SetupLlamaServerCommandEnv(cmd, llamaServer, libDirs, extraEnvs)
var stderr bytes.Buffer
cmd.Stderr = &stderr
stdout, err := cmd.Output()
if err != nil {
if ctx.Err() != nil {
return nil, stderr.String(), ctx.Err()
}
return nil, stderr.String(), err
}
var result nativeProbeResult
if err := json.Unmarshal(stdout, &result); err != nil {
return nil, stderr.String(), err
}
return result.Devices, stderr.String(), nil
}
func RunNativeProbeCommand(ctx context.Context, libDirs []string, out io.Writer) error {
if len(libDirs) == 0 {
libDirs = []string{ml.LibOllamaPath}
}
devices, err := runNativeProbe(ctx, libDirs)
if err != nil {
return err
}
return json.NewEncoder(out).Encode(nativeProbeResult{Devices: devices})
}
func runNativeProbe(ctx context.Context, libDirs []string) ([]nativeProbeDevice, error) {
return runPlatformNativeProbe(ctx, libDirs)
}
func mergeNativeProbeDevices(base, supplement []nativeProbeDevice) []nativeProbeDevice {
if len(base) == 0 {
var out []nativeProbeDevice
for _, extra := range supplement {
if extra.IndexMatchesBackend {
out = append(out, extra)
}
}
return out
}
out := append([]nativeProbeDevice(nil), base...)
for _, extra := range supplement {
idx := -1
for i := range out {
if sameNativeProbeDevice(out[i], extra) {
idx = i
break
}
}
if idx < 0 {
if !extra.IndexMatchesBackend || nativeProbeLibraryIndexExists(out, extra) {
continue
}
out = append(out, extra)
continue
}
mergeNativeProbeDevice(&out[idx], extra)
}
return out
}
func sameNativeProbeDevice(a, b nativeProbeDevice) bool {
if !strings.EqualFold(a.Library, b.Library) {
return false
}
if a.DeviceID != "" && b.DeviceID != "" {
return strings.EqualFold(a.DeviceID, b.DeviceID)
}
if !a.IndexMatchesBackend || !b.IndexMatchesBackend {
return false
}
return a.Index == b.Index
}
func mergeNativeProbeDevice(dst *nativeProbeDevice, src nativeProbeDevice) {
dst.IndexMatchesBackend = dst.IndexMatchesBackend || src.IndexMatchesBackend
if dst.Name == "" {
dst.Name = src.Name
}
if dst.Description == "" {
dst.Description = src.Description
}
if dst.DeviceID == "" {
dst.DeviceID = src.DeviceID
}
if src.IntegratedKnown {
dst.Integrated = src.Integrated
dst.IntegratedKnown = true
} else if !dst.IntegratedKnown && src.Integrated {
dst.Integrated = true
}
if dst.TotalMemory == 0 {
dst.TotalMemory = src.TotalMemory
}
if dst.FreeMemory == 0 {
dst.FreeMemory = src.FreeMemory
}
if dst.ComputeMajor == 0 && src.ComputeMajor != 0 {
dst.ComputeMajor = src.ComputeMajor
dst.ComputeMinor = src.ComputeMinor
}
if dst.CUDADriverMajor == 0 && src.CUDADriverMajor != 0 {
dst.CUDADriverMajor = src.CUDADriverMajor
dst.CUDADriverMinor = src.CUDADriverMinor
}
if dst.NVIDIADriverMajor == 0 && src.NVIDIADriverMajor != 0 {
dst.NVIDIADriverMajor = src.NVIDIADriverMajor
}
if dst.GFXTarget == "" {
dst.GFXTarget = src.GFXTarget
}
}
func nativeProbeLibraryIndexExists(devices []nativeProbeDevice, target nativeProbeDevice) bool {
if !target.IndexMatchesBackend {
return false
}
for _, dev := range devices {
if strings.EqualFold(dev.Library, target.Library) && dev.Index == target.Index {
return true
}
}
return false
}
func nativeProbeByLibraryIndex(devices []nativeProbeDevice) map[string]map[int]nativeProbeDevice {
out := map[string]map[int]nativeProbeDevice{}
for _, dev := range devices {
if !dev.IndexMatchesBackend {
continue
}
lib := normalizeNativeProbeLibrary(dev.Library)
if lib == "" {
continue
}
if _, ok := out[lib]; !ok {
out[lib] = map[int]nativeProbeDevice{}
}
out[lib][dev.Index] = dev
}
return out
}
func normalizeNativeProbeLibrary(library string) string {
switch strings.ToLower(library) {
case "cuda":
return "CUDA"
case "hip", "rocm":
return "ROCm"
case "vulkan":
return "Vulkan"
case "metal":
return "Metal"
default:
return library
}
}
func logNativeProbeFailure(err error, stderr string, libDirs []string) {
if err == nil {
return
}
if stderr != "" {
slog.Debug("native GPU discovery failed", "error", err, "stderr", stderr, "libDirs", libDirs)
return
}
slog.Debug("native GPU discovery failed", "error", err, "libDirs", libDirs)
}
+510
View File
@@ -0,0 +1,510 @@
//go:build linux
package discover
/*
#cgo linux LDFLAGS: -ldl
#include <dlfcn.h>
#include <stddef.h>
#include <stdint.h>
#include <stdlib.h>
static void * ollama_dlopen(const char * path, int global) {
return dlopen(path, RTLD_NOW | (global ? RTLD_GLOBAL : RTLD_LOCAL));
}
static void * ollama_dlsym(void * handle, const char * name) {
return dlsym(handle, name);
}
static const char * ollama_dlerror(void) {
const char * err = dlerror();
return err ? err : "";
}
typedef void * (*ollama_ggml_backend_load_fn)(const char *);
typedef size_t (*ollama_ggml_backend_reg_dev_count_fn)(void *);
typedef void * (*ollama_ggml_backend_reg_dev_get_fn)(void *, size_t);
typedef const char * (*ollama_ggml_backend_reg_name_fn)(void *);
typedef void (*ollama_ggml_backend_dev_get_props_fn)(void *, void *);
static void * ollama_call_ggml_backend_load(void * fn, const char * path) {
return ((ollama_ggml_backend_load_fn) fn)(path);
}
static size_t ollama_call_ggml_backend_reg_dev_count(void * fn, void * reg) {
return ((ollama_ggml_backend_reg_dev_count_fn) fn)(reg);
}
static void * ollama_call_ggml_backend_reg_dev_get(void * fn, void * reg, size_t index) {
return ((ollama_ggml_backend_reg_dev_get_fn) fn)(reg, index);
}
static const char * ollama_call_ggml_backend_reg_name(void * fn, void * reg) {
return ((ollama_ggml_backend_reg_name_fn) fn)(reg);
}
static void ollama_call_ggml_backend_dev_get_props(void * fn, void * dev, void * props) {
((ollama_ggml_backend_dev_get_props_fn) fn)(dev, props);
}
static const char * ollama_cstr_from_uintptr(uintptr_t ptr) {
return (const char *) ptr;
}
typedef int (*ollama_cu_init_fn)(unsigned int);
typedef int (*ollama_cu_driver_get_version_fn)(int *);
typedef int (*ollama_cu_device_get_count_fn)(int *);
typedef int (*ollama_cu_device_get_fn)(int *, int);
typedef int (*ollama_cu_device_get_attribute_fn)(int *, int, int);
typedef int (*ollama_cu_device_get_name_fn)(char *, int, int);
typedef int (*ollama_cu_device_total_mem_fn)(size_t *, int);
typedef int (*ollama_cu_device_get_pci_bus_id_fn)(char *, int, int);
static int ollama_call_cu_init(void * fn) {
return ((ollama_cu_init_fn) fn)(0);
}
static int ollama_call_cu_driver_get_version(void * fn, int * version) {
return ((ollama_cu_driver_get_version_fn) fn)(version);
}
static int ollama_call_cu_device_get_count(void * fn, int * count) {
return ((ollama_cu_device_get_count_fn) fn)(count);
}
static int ollama_call_cu_device_get(void * fn, int * device, int index) {
return ((ollama_cu_device_get_fn) fn)(device, index);
}
static int ollama_call_cu_device_get_attribute(void * fn, int * value, int attr, int device) {
return ((ollama_cu_device_get_attribute_fn) fn)(value, attr, device);
}
static int ollama_call_cu_device_get_name(void * fn, char * name, int len, int device) {
return ((ollama_cu_device_get_name_fn) fn)(name, len, device);
}
static int ollama_call_cu_device_total_mem(void * fn, size_t * total, int device) {
return ((ollama_cu_device_total_mem_fn) fn)(total, device);
}
static int ollama_call_cu_device_get_pci_bus_id(void * fn, char * pci, int len, int device) {
return ((ollama_cu_device_get_pci_bus_id_fn) fn)(pci, len, device);
}
typedef int (*ollama_nvml_init_fn)(void);
typedef int (*ollama_nvml_shutdown_fn)(void);
typedef int (*ollama_nvml_system_get_driver_version_fn)(char *, unsigned int);
static int ollama_call_nvml_init(void * fn) {
return ((ollama_nvml_init_fn) fn)();
}
static int ollama_call_nvml_shutdown(void * fn) {
return ((ollama_nvml_shutdown_fn) fn)();
}
static int ollama_call_nvml_system_get_driver_version(void * fn, char * version, unsigned int len) {
return ((ollama_nvml_system_get_driver_version_fn) fn)(version, len);
}
*/
import "C"
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"unsafe"
)
const (
cuSuccess = 0
cuDeviceAttributeComputeCapabilityMajor = 75
cuDeviceAttributeComputeCapabilityMinor = 76
cuDeviceAttributeIntegrated = 18
)
type dlHandle struct {
ptr unsafe.Pointer
}
func runPlatformNativeProbe(ctx context.Context, libDirs []string) ([]nativeProbeDevice, error) {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
ggmlDevices, ggmlErr := probeGGMLDevicesLinux(libDirs)
var cudaDevices []nativeProbeDevice
var cudaErr error
if nativeProbeHasCUDA(libDirs) {
cudaDevices, cudaErr = probeCUDADriverLinux()
}
var rocmDevices []nativeProbeDevice
var rocmErr error
if nativeProbeHasROCm(libDirs) {
rocmDevices, rocmErr = probeROCmSysfsLinux()
}
devices := mergeNativeProbeDevices(mergeNativeProbeDevices(ggmlDevices, cudaDevices), rocmDevices)
if len(devices) > 0 {
return devices, nil
}
if ggmlErr != nil {
return nil, ggmlErr
}
if rocmErr != nil {
return nil, rocmErr
}
return nil, cudaErr
}
func probeGGMLDevicesLinux(libDirs []string) ([]nativeProbeDevice, error) {
if len(libDirs) == 0 {
return nil, errors.New("no library directories provided")
}
baseDir := libDirs[0]
if baseDir == "" {
return nil, errors.New("empty GGML library directory")
}
base, err := dlopen(ggmlLibraryFile(baseDir, "ggml-base"), true)
if err != nil {
return nil, err
}
ggml, err := dlopen(ggmlLibraryFile(baseDir, "ggml"), true)
if err != nil {
return nil, err
}
backendLoad, err := dlsym(ggml, "ggml_backend_load")
if err != nil {
return nil, err
}
regDevCount, err := dlsym(base, "ggml_backend_reg_dev_count")
if err != nil {
return nil, err
}
regDevGet, err := dlsym(base, "ggml_backend_reg_dev_get")
if err != nil {
return nil, err
}
regName, err := dlsym(base, "ggml_backend_reg_name")
if err != nil {
return nil, err
}
devGetProps, err := dlsym(base, "ggml_backend_dev_get_props")
if err != nil {
return nil, err
}
var devices []nativeProbeDevice
for _, backendPath := range nativeProbeBackendFiles(libDirs) {
reg := callGGMLBackendLoad(backendLoad, backendPath)
if reg == nil {
continue
}
library := ggmlProbeLibraryName(callGGMLRegName(regName, reg))
count := int(callGGMLRegDevCount(regDevCount, reg))
for i := range count {
dev := callGGMLRegDevGet(regDevGet, reg, i)
if dev == nil {
continue
}
props := callGGMLDeviceProps(devGetProps, dev)
if props.MemoryTotal == 0 {
continue
}
devices = append(devices, nativeProbeDevice{
Library: library,
Index: i,
IndexMatchesBackend: true,
Name: cString(props.Name),
Description: cString(props.Description),
DeviceID: cString(props.DeviceID),
Integrated: ggmlDeviceTypeIntegrated(props.Type),
IntegratedKnown: props.Type == ggmlBackendDeviceTypeGPU ||
props.Type == ggmlBackendDeviceTypeIGPU,
TotalMemory: uint64(props.MemoryTotal),
FreeMemory: uint64(props.MemoryFree),
})
slog.Debug("GGML GPU device type", "library", library, "index", i, "ggml_type", props.Type, "integrated", ggmlDeviceTypeIntegrated(props.Type))
}
}
return devices, nil
}
func probeCUDADriverLinux() ([]nativeProbeDevice, error) {
cuda, err := dlopenFirst([]string{"libcuda.so.1", "libcuda.so"}, false)
if err != nil {
return nil, err
}
cuInit, err := dlsym(cuda, "cuInit")
if err != nil {
return nil, err
}
cuDriverGetVersion, err := dlsym(cuda, "cuDriverGetVersion")
if err != nil {
return nil, err
}
cuDeviceGetCount, err := dlsym(cuda, "cuDeviceGetCount")
if err != nil {
return nil, err
}
cuDeviceGet, err := dlsym(cuda, "cuDeviceGet")
if err != nil {
return nil, err
}
cuDeviceGetAttribute, err := dlsym(cuda, "cuDeviceGetAttribute")
if err != nil {
return nil, err
}
cuDeviceGetName, err := dlsym(cuda, "cuDeviceGetName")
if err != nil {
return nil, err
}
cuDeviceTotalMem, err := dlsymAny(cuda, "cuDeviceTotalMem_v2", "cuDeviceTotalMem")
if err != nil {
return nil, err
}
cuDeviceGetPCIBusID, _ := dlsym(cuda, "cuDeviceGetPCIBusId")
if ret := C.ollama_call_cu_init(cuInit); ret != cuSuccess {
return nil, fmt.Errorf("cuInit failed: %d", int(ret))
}
var driverVersion C.int
driverMajor, driverMinor := 0, 0
if ret := C.ollama_call_cu_driver_get_version(cuDriverGetVersion, &driverVersion); ret == cuSuccess {
version := int(driverVersion)
driverMajor = version / 1000
driverMinor = (version - driverMajor*1000) / 10
}
nvidiaDriverMajor := 0
if driver, err := probeNVIDIADriverMajorLinux(); err == nil {
nvidiaDriverMajor = driver
}
var count C.int
if ret := C.ollama_call_cu_device_get_count(cuDeviceGetCount, &count); ret != cuSuccess {
return nil, fmt.Errorf("cuDeviceGetCount failed: %d", int(ret))
}
deviceCount := int(count)
devices := make([]nativeProbeDevice, 0, deviceCount)
for i := range deviceCount {
var device C.int
if ret := C.ollama_call_cu_device_get(cuDeviceGet, &device, C.int(i)); ret != cuSuccess {
continue
}
major := cudaDeviceAttribute(cuDeviceGetAttribute, cuDeviceAttributeComputeCapabilityMajor, device)
minor := cudaDeviceAttribute(cuDeviceGetAttribute, cuDeviceAttributeComputeCapabilityMinor, device)
integrated := cudaDeviceAttribute(cuDeviceGetAttribute, cuDeviceAttributeIntegrated, device) == 1
var name [128]C.char
_ = C.ollama_call_cu_device_get_name(cuDeviceGetName, &name[0], C.int(len(name)), device)
var total C.size_t
_ = C.ollama_call_cu_device_total_mem(cuDeviceTotalMem, &total, device)
pci := ""
if cuDeviceGetPCIBusID != nil {
var pciBuf [32]C.char
if ret := C.ollama_call_cu_device_get_pci_bus_id(cuDeviceGetPCIBusID, &pciBuf[0], C.int(len(pciBuf)), device); ret == cuSuccess {
pci = strings.ToLower(C.GoString(&pciBuf[0]))
}
}
devices = append(devices, nativeProbeDevice{
Library: "CUDA",
Index: i,
IndexMatchesBackend: true,
Description: C.GoString(&name[0]),
DeviceID: pci,
Integrated: integrated,
IntegratedKnown: true,
TotalMemory: uint64(total),
ComputeMajor: major,
ComputeMinor: minor,
CUDADriverMajor: driverMajor,
CUDADriverMinor: driverMinor,
NVIDIADriverMajor: nvidiaDriverMajor,
})
}
return devices, nil
}
func probeROCmSysfsLinux() ([]nativeProbeDevice, error) {
sysfsDevices, err := readROCmLinuxSysfsDevices("/sys")
if err != nil {
return nil, err
}
override := hsaOverrideGFXTarget()
// Sysfs stays in physical KFD order; ROCm visibility envs can reindex the
// backend device list, so filtered sysfs data must merge by PCI ID only.
backendIndex := !rocmVisibleDevicesEnvSet()
devices := make([]nativeProbeDevice, 0, len(sysfsDevices))
for i, sysfsDevice := range sysfsDevices {
gfxTarget := sysfsDevice.gfxTarget
if override != "" {
gfxTarget = override
}
devices = append(devices, nativeProbeDevice{
Library: "ROCm",
Index: i,
IndexMatchesBackend: backendIndex,
DeviceID: sysfsDevice.pciID,
Integrated: sysfsDevice.integrated,
IntegratedKnown: sysfsDevice.known,
GFXTarget: gfxTarget,
})
}
return devices, nil
}
func rocmVisibleDevicesEnvSet() bool {
for _, name := range []string{"HIP_VISIBLE_DEVICES", "ROCR_VISIBLE_DEVICES", "GPU_DEVICE_ORDINAL"} {
if os.Getenv(name) != "" {
return true
}
}
return false
}
func probeNVIDIADriverMajorLinux() (int, error) {
nvml, err := dlopenFirst([]string{"libnvidia-ml.so.1", "libnvidia-ml.so"}, false)
if err != nil {
return 0, err
}
initFn, err := dlsym(nvml, "nvmlInit_v2")
if err != nil {
return 0, err
}
shutdownFn, err := dlsym(nvml, "nvmlShutdown")
if err != nil {
return 0, err
}
driverFn, err := dlsym(nvml, "nvmlSystemGetDriverVersion")
if err != nil {
return 0, err
}
if ret := C.ollama_call_nvml_init(initFn); ret != 0 {
return 0, fmt.Errorf("nvmlInit_v2 failed: %d", int(ret))
}
defer C.ollama_call_nvml_shutdown(shutdownFn)
var version [80]C.char
if ret := C.ollama_call_nvml_system_get_driver_version(driverFn, &version[0], C.uint(len(version))); ret != 0 {
return 0, fmt.Errorf("nvmlSystemGetDriverVersion failed: %d", int(ret))
}
return parseNVIDIADriverMajor(C.GoString(&version[0]))
}
func cudaDeviceAttribute(fn unsafe.Pointer, attr int, device C.int) int {
var value C.int
if ret := C.ollama_call_cu_device_get_attribute(fn, &value, C.int(attr), device); ret != cuSuccess {
return 0
}
return int(value)
}
func dlopenFirst(names []string, global bool) (dlHandle, error) {
var errs []string
for _, name := range names {
handle, err := dlopen(name, global)
if err == nil {
return handle, nil
}
errs = append(errs, err.Error())
}
return dlHandle{}, errors.New(strings.Join(errs, "; "))
}
func dlopen(path string, global bool) (dlHandle, error) {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
handle := C.ollama_dlopen(cpath, boolToCInt(global))
if handle == nil {
return dlHandle{}, fmt.Errorf("dlopen %s: %s", path, C.GoString(C.ollama_dlerror()))
}
return dlHandle{ptr: handle}, nil
}
func dlsym(handle dlHandle, name string) (unsafe.Pointer, error) {
cname := C.CString(name)
defer C.free(unsafe.Pointer(cname))
sym := C.ollama_dlsym(handle.ptr, cname)
if sym == nil {
return nil, fmt.Errorf("dlsym %s: %s", name, C.GoString(C.ollama_dlerror()))
}
return sym, nil
}
func dlsymAny(handle dlHandle, names ...string) (unsafe.Pointer, error) {
var errs []string
for _, name := range names {
sym, err := dlsym(handle, name)
if err == nil {
return sym, nil
}
errs = append(errs, err.Error())
}
return nil, errors.New(strings.Join(errs, "; "))
}
func callGGMLBackendLoad(fn unsafe.Pointer, path string) unsafe.Pointer {
cpath := C.CString(path)
defer C.free(unsafe.Pointer(cpath))
return C.ollama_call_ggml_backend_load(fn, cpath)
}
func callGGMLRegDevCount(fn unsafe.Pointer, reg unsafe.Pointer) uintptr {
return uintptr(C.ollama_call_ggml_backend_reg_dev_count(fn, reg))
}
func callGGMLRegDevGet(fn unsafe.Pointer, reg unsafe.Pointer, index int) unsafe.Pointer {
return C.ollama_call_ggml_backend_reg_dev_get(fn, reg, C.size_t(index))
}
func callGGMLRegName(fn unsafe.Pointer, reg unsafe.Pointer) string {
return C.GoString(C.ollama_call_ggml_backend_reg_name(fn, reg))
}
func callGGMLDeviceProps(fn unsafe.Pointer, dev unsafe.Pointer) ggmlBackendDevProps {
var props ggmlBackendDevProps
C.ollama_call_ggml_backend_dev_get_props(fn, dev, unsafe.Pointer(&props))
return props
}
func cString(ptr uintptr) string {
if ptr == 0 {
return ""
}
return C.GoString(C.ollama_cstr_from_uintptr(C.uintptr_t(ptr)))
}
func boolToCInt(v bool) C.int {
if v {
return 1
}
return 0
}
+12
View File
@@ -0,0 +1,12 @@
//go:build linux && !cgo
package discover
import (
"context"
"errors"
)
func runPlatformNativeProbe(context.Context, []string) ([]nativeProbeDevice, error) {
return nil, errors.New("native GPU discovery requires cgo on Linux")
}
+132
View File
@@ -0,0 +1,132 @@
//go:build (linux && cgo) || windows
package discover
import (
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
)
const (
ggmlBackendDeviceTypeGPU = 1
ggmlBackendDeviceTypeIGPU = 2
)
func ggmlDeviceTypeIntegrated(deviceType int32) bool {
return deviceType == ggmlBackendDeviceTypeIGPU
}
func ggmlProbeLibraryName(name string) string {
switch strings.ToLower(name) {
case "cuda":
return "CUDA"
case "hip", "rocm":
return "ROCm"
case "vulkan":
return "Vulkan"
case "metal":
return "Metal"
default:
return name
}
}
func ggmlLibraryFile(dir, name string) string {
if runtime.GOOS == "windows" {
return filepath.Join(dir, name+".dll")
}
exact := filepath.Join(dir, "lib"+name+".so")
if _, err := os.Stat(exact); err == nil {
return exact
}
matches, _ := filepath.Glob(exact + ".*")
if len(matches) > 0 {
sort.Strings(matches)
return matches[len(matches)-1]
}
return exact
}
func nativeProbeBackendFiles(libDirs []string) []string {
var files []string
seen := map[string]bool{}
for _, dir := range libDirs {
for _, pattern := range nativeProbeBackendPatterns(dir) {
matches, _ := filepath.Glob(pattern)
for _, match := range matches {
if seen[match] {
continue
}
seen[match] = true
files = append(files, match)
}
}
}
return files
}
func nativeProbeBackendPatterns(dir string) []string {
if runtime.GOOS == "windows" {
return []string{
filepath.Join(dir, "ggml-cuda.dll"),
filepath.Join(dir, "ggml-hip.dll"),
filepath.Join(dir, "ggml-vulkan.dll"),
}
}
return []string{
filepath.Join(dir, "libggml-cuda.so"),
filepath.Join(dir, "libggml-hip.so"),
filepath.Join(dir, "libggml-vulkan.so"),
}
}
func nativeProbeHasCUDA(libDirs []string) bool {
for _, dir := range libDirs {
if strings.Contains(strings.ToLower(filepath.Base(dir)), "cuda") {
return true
}
}
for _, file := range nativeProbeBackendFiles(libDirs) {
if strings.Contains(strings.ToLower(filepath.Base(file)), "cuda") {
return true
}
}
return false
}
func nativeProbeHasROCm(libDirs []string) bool {
for _, dir := range libDirs {
base := strings.ToLower(filepath.Base(dir))
if strings.Contains(base, "rocm") || strings.Contains(base, "hip") {
return true
}
}
for _, file := range nativeProbeBackendFiles(libDirs) {
base := strings.ToLower(filepath.Base(file))
if strings.Contains(base, "hip") {
return true
}
}
return false
}
func parseNVIDIADriverMajor(version string) (int, error) {
version = strings.TrimSpace(version)
if version == "" {
return 0, errors.New("empty NVIDIA driver version")
}
major, _, _ := strings.Cut(version, ".")
driver, err := strconv.Atoi(major)
if err != nil {
return 0, fmt.Errorf("parse NVIDIA driver version %q: %w", version, err)
}
return driver, nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !linux && !windows
package discover
import (
"context"
"errors"
)
func runPlatformNativeProbe(context.Context, []string) ([]nativeProbeDevice, error) {
return nil, errors.New("native GPU discovery is not implemented on this platform")
}
Loaded 100 of 1203 files, more files were not shown because too many files have changed in this diff. Show more