mirror of
https://github.com/ollama/ollama.git
synced 2026-09-09 20:53:00 -04:00
Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6b975607 | ||
|
|
8c432fc88a | ||
|
|
acfb50d9af | ||
|
|
0f047feef5 | ||
|
|
9e4ed74efe | ||
|
|
bbb40a0a6c | ||
|
|
993acc7504 | ||
|
|
7ea692cb2b | ||
|
|
12e04379cd | ||
|
|
f8a48df24d | ||
|
|
82e0ddb6fe | ||
|
|
1abd56b6e6 | ||
|
|
ded2db7d86 | ||
|
|
d00622060f | ||
|
|
177aefb8a9 | ||
|
|
07588c64ee | ||
|
|
4c97a940ca | ||
|
|
74cbf1d2c2 | ||
|
|
5c1e37eb67 | ||
|
|
f0078ae476 | ||
|
|
96201a623a | ||
|
|
9c94c2b11e | ||
|
|
e09b3f9fb5 | ||
|
|
a0099da2d1 | ||
|
|
25e0e81e12 | ||
|
|
87cff95af8 | ||
|
|
3ef69ef784 | ||
|
|
1a7786be14 | ||
|
|
3370ff8b1c | ||
|
|
455f57457d | ||
|
|
1d955ed990 | ||
|
|
d071237131 | ||
|
|
229a1303fb | ||
|
|
ac3d0657a2 | ||
|
|
01557ff313 | ||
|
|
e5a38739b4 | ||
|
|
5f56a289b3 | ||
|
|
ad8cda255d | ||
|
|
3e1b4fe39d | ||
|
|
52196f1a97 | ||
|
|
50bbda5660 | ||
|
|
4b5bdd3b25 | ||
|
|
e828061b6e | ||
|
|
7a2073d17b | ||
|
|
c952708169 | ||
|
|
f57d111754 | ||
|
|
c34a79a373 | ||
|
|
b051c9cf83 | ||
|
|
b7b7fa0454 | ||
|
|
4c076813be | ||
|
|
6780f0416a | ||
|
|
35fa277fa9 | ||
|
|
05747b02ab | ||
|
|
4e807fdedd | ||
|
|
7d3a6c3ae5 | ||
|
|
06ff728246 | ||
|
|
2c71d8d7ca | ||
|
|
00381496a3 | ||
|
|
5e9636fa05 | ||
|
|
630882621b | ||
|
|
0e93ccc2cd | ||
|
|
e7766a4a47 | ||
|
|
be7de10c41 | ||
|
|
11be8f6ac8 | ||
|
|
9db4bdbad6 |
No files matched your search
+144
-92
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
See `AGENTS.md` for the shared agent instructions for this repository.
|
||||
+21
-332
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
b9672
|
||||
+1
-1
@@ -1 +1 @@
|
||||
e8ebdebeeb655feaa85a51f6b24ece5b6d5518d1
|
||||
2165dc08d7b33258260aa849d39f087d50e62962
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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="" />,
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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.")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user