mirror of
https://github.com/ollama/ollama.git
synced 2026-09-08 12:13:43 -04:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87fb711e0c | ||
|
|
11be8f6ac8 | ||
|
|
9db4bdbad6 | ||
|
|
f63eea3d27 | ||
|
|
632ff00798 | ||
|
|
275f122cda | ||
|
|
32568531bd | ||
|
|
438fb991e4 | ||
|
|
358af4af23 | ||
|
|
91c8e5e1a8 | ||
|
|
4b2d529966 | ||
|
|
e6b1d751f2 | ||
|
|
56b319f457 | ||
|
|
42e6f56c2a | ||
|
|
da679adcde | ||
|
|
b9c0421f03 | ||
|
|
98e26b8c37 | ||
|
|
c28ddc0a7b | ||
|
|
3ad2fa3fb5 | ||
|
|
6b6f45ef0e | ||
|
|
4860130f83 | ||
|
|
ac7295ccab | ||
|
|
6398cd5b78 | ||
|
|
3af1a008e2 | ||
|
|
6bdb73073b | ||
|
|
421faa0263 | ||
|
|
206b049508 | ||
|
|
d819ef0f97 | ||
|
|
3d5a011a2e | ||
|
|
c2f2d90a67 | ||
|
|
1e1b34dada |
No files matched your search
+261
-106
@@ -16,7 +16,7 @@ jobs:
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
run: |
|
||||
echo GOFLAGS="'-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${GITHUB_REF_NAME#v}\" \"-X=github.com/ollama/ollama/server.mode=release\"'" | tee -a $GITHUB_OUTPUT
|
||||
echo VERSION="${GITHUB_REF_NAME#v}" | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
@@ -57,7 +57,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- run: |
|
||||
./scripts/build_darwin.sh
|
||||
- name: Log build results
|
||||
@@ -73,15 +75,18 @@ jobs:
|
||||
dist/*.dmg
|
||||
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
preset: ['CPU']
|
||||
build-steps: ['cpu cpuArm64']
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 12'
|
||||
build-steps: cuda12
|
||||
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -89,10 +94,10 @@ jobs:
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
@@ -103,23 +108,23 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 6'
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
rocm-version: '6.2'
|
||||
flags: '-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
runner_dir: 'rocm'
|
||||
preset: 'ROCm 7'
|
||||
build-steps: rocm7
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: Vulkan
|
||||
build-steps: vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
flags: ''
|
||||
runner_dir: 'vulkan'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'MLX CUDA 13'
|
||||
build-steps: mlxCuda13
|
||||
build-parallel: '16'
|
||||
cmake-cuda-flags: '-t 6'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
cuda-components:
|
||||
@@ -135,18 +140,34 @@ jobs:
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
flags: ''
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Increase pagefile to 200 GB
|
||||
uses: al-cheb/configure-pagefile-action@v1.5
|
||||
with:
|
||||
minimum-size: 16GB
|
||||
maximum-size: 200GB
|
||||
disk-root: "D:"
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan') || startsWith(matrix.preset, 'MLX ')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
@@ -195,12 +216,12 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'CPU'
|
||||
run: |
|
||||
echo "CC=clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: startsWith(matrix.preset, 'MLX ')
|
||||
name: Install cuDNN for MLX
|
||||
run: |
|
||||
@@ -232,72 +253,63 @@ jobs:
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build target "${{ matrix.preset }}"
|
||||
- name: Build Windows dependencies
|
||||
run: |
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }} --install-prefix "$((pwd).Path)\dist\${{ matrix.os }}-${{ matrix.arch }}"
|
||||
cmake --build --parallel ([Environment]::ProcessorCount) --preset "${{ matrix.preset }}"
|
||||
cmake --install build --component "${{ startsWith(matrix.preset, 'MLX ') && 'MLX' || startsWith(matrix.preset, 'CUDA ') && 'CUDA' || startsWith(matrix.preset, 'ROCm ') && 'HIP' || startsWith(matrix.preset, 'Vulkan') && 'Vulkan' || 'CPU' }}" --strip
|
||||
Remove-Item -Path dist\lib\ollama\rocm\rocblas\library\*gfx906* -ErrorAction SilentlyContinue
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
OLLAMA_BUILD_PARALLEL: ${{ matrix.build-parallel || '' }}
|
||||
OLLAMA_CMAKE_CUDA_FLAGS: ${{ matrix.cmake-cuda-flags || '' }}
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
|
||||
windows-build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64, arm64]
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
llvmarch: x86_64
|
||||
- os: windows
|
||||
arch: arm64
|
||||
llvmarch: aarch64
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [setup-environment]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install ARM64 system dependencies
|
||||
if: matrix.arch == 'arm64'
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||
echo "C:\ProgramData\chocolatey\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
Invoke-WebRequest -Uri https://aka.ms/vs/17/release/vc_redist.arm64.exe -OutFile "${{ runner.temp }}\vc_redist.arm64.exe"
|
||||
Start-Process -FilePath "${{ runner.temp }}\vc_redist.arm64.exe" -ArgumentList @("/install", "/quiet", "/norestart") -NoNewWindow -Wait
|
||||
|
||||
choco install -y --no-progress git gzip
|
||||
echo "C:\Program Files\Git\cmd" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-${{ matrix.llvmarch }}.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt*").path
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
@@ -314,20 +326,30 @@ jobs:
|
||||
with:
|
||||
node-version: "20"
|
||||
- run: |
|
||||
./scripts/build_windows ollama app
|
||||
./scripts/build_windows ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-${{ matrix.os }}-${{ matrix.arch }}
|
||||
name: build-windows-amd64
|
||||
path: |
|
||||
dist\*
|
||||
|
||||
windows-app:
|
||||
runs-on: windows
|
||||
environment: release
|
||||
needs: [windows-build, windows-depends]
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
@@ -353,7 +375,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
@@ -367,6 +391,18 @@ jobs:
|
||||
- name: Log dist contents after download
|
||||
run: |
|
||||
gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- run: |
|
||||
./scripts/build_windows.ps1 deps sign installer zip
|
||||
- name: Log contents after build
|
||||
@@ -380,20 +416,33 @@ jobs:
|
||||
dist/*.ps1
|
||||
dist/OllamaSetup.exe
|
||||
|
||||
linux-build:
|
||||
linux-depends:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
arch: amd64
|
||||
target: archive
|
||||
- os: linux
|
||||
arch: amd64
|
||||
target: rocm
|
||||
- os: linux
|
||||
arch: arm64
|
||||
target: archive
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
- arch: amd64
|
||||
target: llama-server-cpu
|
||||
- arch: amd64
|
||||
target: llama-server-cuda_v12
|
||||
- arch: amd64
|
||||
target: llama-server-cuda_v13
|
||||
- arch: amd64
|
||||
target: mlx
|
||||
- arch: amd64
|
||||
target: llama-server-rocm_v7_2
|
||||
- arch: amd64
|
||||
target: llama-server-vulkan
|
||||
- arch: arm64
|
||||
target: llama-server-cpu
|
||||
- arch: arm64
|
||||
target: llama-server-cuda_v12
|
||||
- arch: arm64
|
||||
target: llama-server-cuda_v13
|
||||
- arch: arm64
|
||||
target: jetpack-5
|
||||
- arch: arm64
|
||||
target: jetpack-6
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
env:
|
||||
@@ -401,83 +450,114 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
|
||||
- if: matrix.target == 'mlx'
|
||||
name: Increase Linux swap to 200 GB
|
||||
shell: bash
|
||||
run: |
|
||||
set -e
|
||||
SWAP_PATH=/swapfile-mlx
|
||||
SWAP_SIZE_GB=200
|
||||
if [ -f "$SWAP_PATH" ]; then
|
||||
sudo swapoff "$SWAP_PATH" 2>/dev/null || true
|
||||
sudo rm -f "$SWAP_PATH"
|
||||
fi
|
||||
if ! sudo fallocate -l ${SWAP_SIZE_GB}G "$SWAP_PATH" 2>/dev/null; then
|
||||
echo "fallocate unsupported, falling back to dd"
|
||||
sudo dd if=/dev/zero of="$SWAP_PATH" bs=1M count=$((SWAP_SIZE_GB * 1024))
|
||||
fi
|
||||
sudo chmod 600 "$SWAP_PATH"
|
||||
sudo mkswap "$SWAP_PATH"
|
||||
sudo swapon "$SWAP_PATH"
|
||||
swapon --show
|
||||
free -h
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: ${{ matrix.target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ env.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-to: type=inline
|
||||
- name: Deduplicate CUDA libraries
|
||||
run: |
|
||||
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- run: |
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "$COMPONENT" in
|
||||
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- run: |
|
||||
echo "Manifests"
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
|
||||
echo $ARCHIVE
|
||||
cat $ARCHIVE
|
||||
done
|
||||
- run: |
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd --ultra -22 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst);
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundles-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.target }}
|
||||
path: |
|
||||
*.tar.zst
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }}
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-to: type=registry,ref=ollama/release:cache-${{ matrix.arch }}-${{ matrix.target }},mode=max
|
||||
|
||||
# Build each Docker variant (OS, arch, and flavor) separately. Using QEMU is unreliable and slower.
|
||||
# Heavy stages were pre-built by linux-depends; this job is cache-hit-only for those layers
|
||||
# and just assembles, runs the Go build, pushes the final image, and extracts release bundles.
|
||||
docker-build-push:
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: linux
|
||||
arch: arm64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-arm64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-5
|
||||
type=registry,ref=ollama/release:cache-arm64-jetpack-6
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
archive-target: archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v12
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cuda_v13
|
||||
type=registry,ref=ollama/release:cache-amd64-mlx
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-vulkan
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
- os: linux
|
||||
arch: amd64
|
||||
suffix: '-rocm'
|
||||
archive-target: image-archive
|
||||
build-args: |
|
||||
CGO_CFLAGS
|
||||
CGO_CXXFLAGS
|
||||
GOFLAGS
|
||||
FLAVOR=rocm
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
OLLAMA_MLX_BUILD_JOBS=16
|
||||
OLLAMA_MLX_NVCC_THREADS=6
|
||||
cache-from: |
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-cpu
|
||||
type=registry,ref=ollama/release:cache-amd64-llama-server-rocm_v7_2
|
||||
type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
environment: release
|
||||
needs: setup-environment
|
||||
needs: [setup-environment, linux-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
steps:
|
||||
@@ -492,9 +572,11 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
outputs: type=image,name=${{ vars.DOCKER_REPO }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=${{ vars.DOCKER_REPO }}:latest
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: type=inline
|
||||
- run: |
|
||||
mkdir -p ${{ matrix.os }}-${{ matrix.arch }}
|
||||
@@ -505,6 +587,64 @@ jobs:
|
||||
name: digest-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}
|
||||
path: |
|
||||
${{ runner.temp }}/${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.suffix }}.txt
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.os }}/${{ matrix.arch }}
|
||||
target: ${{ matrix.archive-target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: ${{ matrix.build-args }}
|
||||
outputs: type=local,dest=dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
- name: Deduplicate CUDA libraries
|
||||
run: |
|
||||
./scripts/deduplicate_cuda_libs.sh dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/${{ matrix.os }}-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"$base/bin/ollama" \
|
||||
"$base/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- run: |
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "$COMPONENT" in
|
||||
bin/ollama*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/${{ matrix.os }}-${{ matrix.arch }}
|
||||
- if: matrix.suffix == '-rocm'
|
||||
run: rm -f dist/${{ matrix.os }}-${{ matrix.arch }}/ollama-${{ matrix.os }}-${{ matrix.arch }}.tar.in
|
||||
- run: |
|
||||
echo "Manifests"
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in ; do
|
||||
echo $ARCHIVE
|
||||
cat $ARCHIVE
|
||||
done
|
||||
- run: |
|
||||
for ARCHIVE in dist/${{ matrix.os }}-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/${{ matrix.os }}-${{ matrix.arch }} -T $ARCHIVE --owner 0 --group 0 | zstd -19 -T0 >$(basename ${ARCHIVE//.*/}.tar.zst) &
|
||||
done
|
||||
wait
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: bundles-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.suffix }}
|
||||
path: |
|
||||
*.tar.zst
|
||||
|
||||
# Merge Docker images for the same flavor into a single multi-arch manifest
|
||||
docker-merge-push:
|
||||
@@ -544,7 +684,7 @@ jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: release
|
||||
needs: [darwin-build, windows-app, linux-build]
|
||||
needs: [darwin-build, windows-app, docker-build-push]
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
@@ -562,6 +702,21 @@ jobs:
|
||||
- name: Copy install scripts to dist
|
||||
run: |
|
||||
cp scripts/install.sh dist/install.sh
|
||||
- name: Verify release artifacts
|
||||
run: |
|
||||
required=(
|
||||
dist/OllamaSetup.exe
|
||||
dist/install.ps1
|
||||
dist/install.sh
|
||||
dist/ollama-windows-amd64.zip
|
||||
dist/ollama-windows-arm64.zip
|
||||
)
|
||||
for payload in "${required[@]}"; do
|
||||
if [ ! -f "$payload" ]; then
|
||||
echo "::error::Missing expected release artifact: $payload"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Generate checksum file
|
||||
run: find . -type f -not -name 'sha256sum.txt' | xargs sha256sum | tee sha256sum.txt
|
||||
working-directory: dist
|
||||
|
||||
+159
-37
@@ -22,7 +22,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.changes.outputs.changed }}
|
||||
vendorsha: ${{ steps.changes.outputs.vendorsha }}
|
||||
app_changed: ${{ steps.changes.outputs.app_changed }}
|
||||
enginehash: ${{ steps.changes.outputs.enginehash }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -37,8 +38,42 @@ jobs:
|
||||
| xargs python3 -c "import sys; from pathlib import Path; print(any(Path(x).match(glob) for x in sys.argv[1:] for glob in '$*'.split(' ')))"
|
||||
}
|
||||
|
||||
echo changed=$(changed 'llama/llama.cpp/**/*' 'ml/backend/ggml/ggml/**/*' '.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo vendorsha=$(make -f Makefile.sync print-base) | tee -a $GITHUB_OUTPUT
|
||||
echo changed=$(changed \
|
||||
'CMakeLists.txt' \
|
||||
'CMakePresets.json' \
|
||||
'cmake/**' \
|
||||
'cmake/**/*' \
|
||||
'llama/server/**/*' \
|
||||
'llama/compat/**/*' \
|
||||
'LLAMA_CPP_VERSION' \
|
||||
'MLX_VERSION' \
|
||||
'MLX_C_VERSION' \
|
||||
'llama/llama.cpp/**/*' \
|
||||
'ml/backend/ggml/ggml/**/*' \
|
||||
'x/imagegen/mlx/**' \
|
||||
'x/imagegen/mlx/**/*' \
|
||||
'.github/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo app_changed=$(changed 'app/**' 'app/**/*') | tee -a $GITHUB_OUTPUT
|
||||
echo enginehash=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION) | tee -a $GITHUB_OUTPUT
|
||||
|
||||
patches:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly
|
||||
shell: bash
|
||||
run: |
|
||||
cmake -S llama/server -B "$RUNNER_TEMP/llama-server-patch-check" \
|
||||
-DCMAKE_BUILD_TYPE=Release \
|
||||
-DBUILD_SHARED_LIBS=ON \
|
||||
-DGGML_BACKEND_DL=ON \
|
||||
-DGGML_NATIVE=OFF \
|
||||
-DGGML_OPENMP=OFF \
|
||||
-DGGML_CPU_ALL_VARIANTS=ON \
|
||||
-DOLLAMA_RUNNER_DIR=
|
||||
|
||||
linux:
|
||||
needs: [changes]
|
||||
@@ -47,23 +82,41 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build/local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib/ollama/llama-server
|
||||
install-go: true
|
||||
- preset: CUDA
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87'
|
||||
expected_payload: lib/ollama/cuda_v13/libggml-cuda.so
|
||||
- preset: ROCm
|
||||
container: rocm/dev-ubuntu-22.04:7.2.1
|
||||
extra-packages: rocm-libs
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
superbuild_target: ollama-llama-server-rocm_v7_2
|
||||
superbuild_dir: build/local-superbuild-rocm_v7_2
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_2 -DAMDGPU_TARGETS=gfx1010 -DCMAKE_PREFIX_PATH=/opt/rocm'
|
||||
expected_payload: lib/ollama/rocm_v7_2/libggml-hip.so
|
||||
- preset: Vulkan
|
||||
container: ubuntu:22.04
|
||||
extra-packages: >
|
||||
mesa-vulkan-drivers vulkan-tools
|
||||
libvulkan1 libvulkan-dev
|
||||
vulkan-sdk cmake ccache g++ make
|
||||
vulkan-sdk spirv-headers cmake ccache g++ make
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build/local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib/ollama/vulkan/libggml-vulkan.so
|
||||
- preset: 'MLX CUDA 13'
|
||||
container: nvidia/cuda:13.0.0-devel-ubuntu22.04
|
||||
extra-packages: libcudnn9-dev-cuda-13 libopenblas-dev liblapack-dev liblapacke-dev git curl
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=87 -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build/local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=87 -DMLX_CUDA_ARCHITECTURES=80-virtual -DBLAS_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu -DLAPACK_INCLUDE_DIRS=/usr/include/x86_64-linux-gnu'
|
||||
expected_payload: lib/ollama/mlx_cuda_v13/libmlx.so
|
||||
install-go: true
|
||||
runs-on: linux
|
||||
container: ${{ matrix.container }}
|
||||
@@ -80,11 +133,9 @@ jobs:
|
||||
echo "deb [signed-by=/usr/share/keyrings/lunarg-archive-keyring.gpg] https://packages.lunarg.com/vulkan/1.4.313 jammy main" | $sudo tee /etc/apt/sources.list.d/lunarg-vulkan-1.4.313-jammy.list > /dev/null
|
||||
$sudo apt-get update
|
||||
fi
|
||||
$sudo apt-get install -y cmake ccache ${{ matrix.extra-packages }}
|
||||
# MLX requires CMake 3.25+, install from official releases
|
||||
if [ "${{ matrix.preset }}" = "MLX CUDA 13" ]; then
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
fi
|
||||
$sudo apt-get install -y cmake ccache curl git ${{ matrix.extra-packages }}
|
||||
# Use a current CMake for upstream llama.cpp and Vulkan dependency discovery.
|
||||
curl -fsSL https://github.com/Kitware/CMake/releases/download/v3.31.2/cmake-3.31.2-linux-$(uname -m).tar.gz | $sudo tar xz -C /usr/local --strip-components 1
|
||||
# Export VULKAN_SDK if provided by LunarG package (defensive)
|
||||
if [ -d "/usr/lib/x86_64-linux-gnu/vulkan" ] && [ "${{ matrix.preset }}" = "Vulkan" ]; then
|
||||
echo "VULKAN_SDK=/usr" >> $GITHUB_ENV
|
||||
@@ -94,17 +145,30 @@ jobs:
|
||||
- if: matrix.install-go
|
||||
name: Install Go
|
||||
run: |
|
||||
[ -n "${{ matrix.container }}" ] || sudo=sudo
|
||||
GO_VERSION=$(awk '/^go / { print $2 }' go.mod)
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | tar xz -C /usr/local
|
||||
curl -fsSL "https://golang.org/dl/go${GO_VERSION}.linux-$(dpkg --print-architecture).tar.gz" | $sudo tar xz -C /usr/local
|
||||
echo "/usr/local/go/bin" >> $GITHUB_PATH
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: /github/home/.cache/ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
|
||||
- run: |
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
|
||||
cmake --build --preset "${{ matrix.preset }}" --parallel
|
||||
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
CMAKE_BUILD_PARALLEL_LEVEL=$(nproc) cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $(nproc)
|
||||
test -e "${{ matrix.superbuild_dir }}/${{ matrix.expected_payload }}"
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
./ollama --version
|
||||
"${{ matrix.superbuild_dir }}/lib/ollama/llama-server" --version
|
||||
test -x "${{ matrix.superbuild_dir }}/lib/ollama/llama-quantize"
|
||||
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$RUNNER_TEMP/ollama-local"
|
||||
"$RUNNER_TEMP/ollama-local/bin/ollama" --version
|
||||
"$RUNNER_TEMP/ollama-local/lib/ollama/llama-server" --version
|
||||
test -x "$RUNNER_TEMP/ollama-local/lib/ollama/llama-quantize"
|
||||
windows:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.changed == 'True'
|
||||
@@ -112,9 +176,16 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- preset: CPU
|
||||
superbuild_target: ollama-local
|
||||
superbuild_dir: build\local-superbuild
|
||||
superbuild_args: ''
|
||||
expected_payload: lib\ollama\llama-server.exe
|
||||
- preset: CUDA
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
superbuild_target: ollama-llama-server-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
expected_payload: lib\ollama\cuda_v13\ggml-cuda.dll
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -125,14 +196,26 @@ jobs:
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- preset: ROCm
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-24.Q4-WinSvr2022-For-HIP.exe
|
||||
flags: '-DAMDGPU_TARGETS=gfx1010 -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ -DCMAKE_C_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma" -DCMAKE_CXX_FLAGS="-parallel-jobs=4 -Wno-ignored-attributes -Wno-deprecated-pragma"'
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
superbuild_target: ollama-llama-server-rocm_v7_1
|
||||
superbuild_dir: build\local-superbuild-rocm_v7_1
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=rocm_v7_1 -DAMDGPU_TARGETS=gfx1010'
|
||||
expected_payload: lib\ollama\rocm_v7_1\ggml-hip.dll
|
||||
- preset: Vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
superbuild_target: ollama-llama-server-vulkan
|
||||
superbuild_dir: build\local-superbuild-vulkan
|
||||
superbuild_args: '-DOLLAMA_LLAMA_BACKENDS=vulkan'
|
||||
expected_payload: lib\ollama\vulkan\ggml-vulkan.dll
|
||||
- preset: 'MLX CUDA 13'
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cudnn-install: https://developer.download.nvidia.com/compute/cudnn/redist/cudnn/windows-x86_64/cudnn-windows-x86_64-9.18.1.3_cuda13-archive.zip
|
||||
flags: '-DCMAKE_CUDA_ARCHITECTURES=80'
|
||||
superbuild_target: ollama-mlx-cuda_v13
|
||||
superbuild_dir: build\local-superbuild-mlx-cuda_v13
|
||||
superbuild_args: '-DOLLAMA_MLX_BACKENDS=cuda_v13 -DCMAKE_CUDA_ARCHITECTURES=80 -DMLX_CUDA_ARCHITECTURES=80-virtual'
|
||||
expected_payload: lib\ollama\mlx_cuda_v13\mlx.dll
|
||||
install-go: true
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
@@ -201,6 +284,10 @@ jobs:
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: matrix.preset == 'MLX CUDA 13'
|
||||
@@ -230,18 +317,44 @@ jobs:
|
||||
C:\Program Files\NVIDIA\CUDNN
|
||||
key: ${{ matrix.install }}-${{ matrix.cudnn-install }}
|
||||
- uses: actions/checkout@v4
|
||||
- if: matrix.superbuild_target == 'ollama-local' || matrix.install-go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.vendorsha }}
|
||||
- run: |
|
||||
key: ccache-${{ runner.os }}-${{ runner.arch }}-${{ matrix.preset }}-${{ needs.changes.outputs.enginehash }}
|
||||
- name: Build native superbuild
|
||||
if: matrix.superbuild_target
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
cmake --preset "${{ matrix.preset }}" ${{ matrix.flags }}
|
||||
cmake --build --parallel --preset "${{ matrix.preset }}"
|
||||
cmake -S . -B "${{ matrix.superbuild_dir }}" ${{ matrix.superbuild_args }}
|
||||
$env:CMAKE_BUILD_PARALLEL_LEVEL = [Environment]::ProcessorCount
|
||||
cmake --build "${{ matrix.superbuild_dir }}" --target "${{ matrix.superbuild_target }}" -- -l $([Environment]::ProcessorCount)
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\${{ matrix.expected_payload }}")) {
|
||||
throw "missing ${{ matrix.expected_payload }}"
|
||||
}
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
|
||||
- name: Verify local superbuild install
|
||||
if: matrix.superbuild_target == 'ollama-local'
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
& ".\ollama.exe" --version
|
||||
& "${{ matrix.superbuild_dir }}\lib\ollama\llama-server.exe" --version
|
||||
if (!(Test-Path "${{ matrix.superbuild_dir }}\lib\ollama\llama-quantize.exe")) {
|
||||
throw "missing llama-quantize.exe"
|
||||
}
|
||||
$installPrefix = Join-Path $env:RUNNER_TEMP "ollama-local"
|
||||
cmake --install "${{ matrix.superbuild_dir }}" --component ollama-local --prefix "$installPrefix"
|
||||
& "$installPrefix\bin\ollama.exe" --version
|
||||
& "$installPrefix\lib\ollama\llama-server.exe" --version
|
||||
if (!(Test-Path "$installPrefix\lib\ollama\llama-quantize.exe")) {
|
||||
throw "missing installed llama-quantize.exe"
|
||||
}
|
||||
go_mod_tidy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -250,6 +363,7 @@ jobs:
|
||||
run: go mod tidy --diff || (echo "Please run 'go mod tidy'." && exit 1)
|
||||
|
||||
test:
|
||||
needs: [changes]
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
@@ -263,7 +377,9 @@ jobs:
|
||||
go-version-file: 'go.mod'
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
Makefile.sync
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
@@ -277,6 +393,17 @@ jobs:
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
working-directory: ./app/ui/app
|
||||
run: npm test
|
||||
- name: Verify MLX generated files are current
|
||||
if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
run: |
|
||||
cmake -S . -B build/mlx-generate -DOLLAMA_MLX_BACKENDS=cuda_v13
|
||||
cmake --build build/mlx-generate --target ollama-mlx-generate-wrappers
|
||||
git diff --exit-code -- \
|
||||
x/imagegen/mlx/mlx.h \
|
||||
x/imagegen/mlx/mlx.c \
|
||||
x/mlxrunner/mlx/generated.h \
|
||||
x/mlxrunner/mlx/generated.c \
|
||||
x/mlxrunner/mlx/include/mlx/c
|
||||
- name: Run go generate
|
||||
run: go generate ./...
|
||||
|
||||
@@ -284,15 +411,10 @@ jobs:
|
||||
if: always()
|
||||
run: go test -count=1 -benchtime=1x ./...
|
||||
|
||||
- name: go test app with live updater tag
|
||||
if: ${{ needs.changes.outputs.app_changed == 'True' && contains(fromJSON('["macos-latest","windows-latest"]'), matrix.os) }}
|
||||
run: go test -count=1 -tags updater_live ./app/...
|
||||
|
||||
- uses: golangci/golangci-lint-action@v9
|
||||
with:
|
||||
only-new-issues: true
|
||||
|
||||
patches:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Verify patches apply cleanly and do not change files
|
||||
run: |
|
||||
make -f Makefile.sync clean checkout apply-patches sync
|
||||
git diff --compact-summary --exit-code
|
||||
+21
-327
@@ -1,4 +1,4 @@
|
||||
cmake_minimum_required(VERSION 3.21)
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
|
||||
project(Ollama C CXX)
|
||||
|
||||
@@ -23,30 +23,23 @@ include(GNUInstallDirs)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
set(CMAKE_BUILD_TYPE Release)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
# These defaults can be overridden by presets (e.g., for static macOS llama-server builds)
|
||||
if(NOT DEFINED BUILD_SHARED_LIBS)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX Requires gnu++17 extensions to compile properly
|
||||
set(CMAKE_CXX_EXTENSIONS ON) # Recent versions of MLX require gnu++17 extensions to compile properly
|
||||
|
||||
set(GGML_BUILD ON)
|
||||
set(GGML_SHARED ON)
|
||||
set(GGML_CCACHE ON)
|
||||
set(GGML_BACKEND_DL ON)
|
||||
set(GGML_BACKEND_SHARED ON)
|
||||
set(GGML_SCHED_MAX_COPIES 4)
|
||||
|
||||
set(GGML_LLAMAFILE ON)
|
||||
set(GGML_CUDA_PEER_MAX_BATCH_SIZE 128)
|
||||
set(GGML_CUDA_GRAPHS ON)
|
||||
set(GGML_CUDA_FA ON)
|
||||
set(GGML_CUDA_COMPRESSION_MODE default)
|
||||
|
||||
if((CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_OSX_ARCHITECTURES MATCHES "arm64")
|
||||
OR (NOT CMAKE_OSX_ARCHITECTURES AND NOT CMAKE_SYSTEM_PROCESSOR MATCHES "arm|aarch64|ARM64|ARMv[0-9]+"))
|
||||
set(GGML_CPU_ALL_VARIANTS ON)
|
||||
endif()
|
||||
# GGML backend for inference is provided by llama-server (built separately via
|
||||
# llama/server/CMakeLists.txt using FetchContent from the pinned llama.cpp source).
|
||||
# The root CMake project is the orchestration entrypoint; backend-specific
|
||||
# build rules live in subprojects under cmake/.
|
||||
|
||||
if(APPLE)
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
@@ -55,7 +48,8 @@ if(APPLE)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||
set(OLLAMA_INSTALL_DIR ${CMAKE_INSTALL_PREFIX}/lib/ollama/${OLLAMA_RUNNER_DIR})
|
||||
set(OLLAMA_LIB_DIR "lib/ollama" CACHE STRING "Install destination for Ollama runtime payloads")
|
||||
set(OLLAMA_INSTALL_DIR ${OLLAMA_LIB_DIR}/${OLLAMA_RUNNER_DIR})
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
@@ -64,309 +58,9 @@ set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
|
||||
|
||||
# Store ggml include paths for use with target_include_directories later.
|
||||
# We avoid global include_directories() to prevent polluting the include path
|
||||
# for other projects like MLX (whose openblas dependency has its own common.h).
|
||||
set(GGML_INCLUDE_DIRS
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/include
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cpu/amx
|
||||
)
|
||||
|
||||
add_compile_definitions(NDEBUG GGML_VERSION=0x0 GGML_COMMIT=0x0)
|
||||
|
||||
# Define GGML version variables for shared library SOVERSION
|
||||
# These are required by ggml/src/CMakeLists.txt for proper library versioning
|
||||
set(GGML_VERSION_MAJOR 0)
|
||||
set(GGML_VERSION_MINOR 0)
|
||||
set(GGML_VERSION_PATCH 0)
|
||||
set(GGML_VERSION "${GGML_VERSION_MAJOR}.${GGML_VERSION_MINOR}.${GGML_VERSION_PATCH}")
|
||||
|
||||
set(GGML_CPU ON)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src)
|
||||
set_property(TARGET ggml PROPERTY EXCLUDE_FROM_ALL TRUE)
|
||||
|
||||
get_target_property(CPU_VARIANTS ggml-cpu MANUALLY_ADDED_DEPENDENCIES)
|
||||
if(NOT CPU_VARIANTS)
|
||||
set(CPU_VARIANTS "ggml-cpu")
|
||||
endif()
|
||||
|
||||
# Apply ggml include directories to ggml targets only (not globally)
|
||||
target_include_directories(ggml-base PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
foreach(variant ${CPU_VARIANTS})
|
||||
if(TARGET ${variant})
|
||||
target_include_directories(${variant} PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
install(TARGETS ggml-base ${CPU_VARIANTS}
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CPU
|
||||
)
|
||||
|
||||
check_language(CUDA)
|
||||
if(CMAKE_CUDA_COMPILER)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL "3.24" AND NOT CMAKE_CUDA_ARCHITECTURES)
|
||||
set(CMAKE_CUDA_ARCHITECTURES "native")
|
||||
endif()
|
||||
|
||||
find_package(CUDAToolkit)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-cuda)
|
||||
target_include_directories(ggml-cuda PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-cuda
|
||||
RUNTIME_DEPENDENCIES
|
||||
DIRECTORIES ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR}
|
||||
PRE_INCLUDE_REGEXES cublas cublasLt cudart
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT CUDA
|
||||
)
|
||||
endif()
|
||||
|
||||
set(WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX "^gfx(908|90a|1200|1201):xnack[+-]$"
|
||||
CACHE STRING
|
||||
"Regular expression describing AMDGPU_TARGETS not supported on Windows. Override to force building these targets. Default \"^gfx(908|90a|1200|1201):xnack[+-]$\"."
|
||||
)
|
||||
|
||||
check_language(HIP)
|
||||
if(CMAKE_HIP_COMPILER)
|
||||
set(HIP_PLATFORM "amd")
|
||||
|
||||
if(NOT AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
list(FILTER AMDGPU_TARGETS INCLUDE REGEX "^gfx(94[012]|101[02]|1030|110[012]|120[01])$")
|
||||
endif()
|
||||
|
||||
if(WIN32 AND WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX)
|
||||
list(FILTER AMDGPU_TARGETS EXCLUDE REGEX ${WINDOWS_AMDGPU_TARGETS_EXCLUDE_REGEX})
|
||||
endif()
|
||||
|
||||
if(AMDGPU_TARGETS)
|
||||
find_package(hip REQUIRED)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-hip)
|
||||
target_include_directories(ggml-hip PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
|
||||
if (WIN32)
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_CUDA_NO_PEER_COPY)
|
||||
endif()
|
||||
|
||||
target_compile_definitions(ggml-hip PRIVATE GGML_HIP_NO_VMM)
|
||||
|
||||
install(TARGETS ggml-hip
|
||||
RUNTIME_DEPENDENCY_SET rocm
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
install(RUNTIME_DEPENDENCY_SET rocm
|
||||
DIRECTORIES ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR}
|
||||
PRE_INCLUDE_REGEXES hipblas rocblas amdhip64 rocsolver amd_comgr hsa-runtime64 rocsparse tinfo rocprofiler-register roctx64 rocroller drm drm_amdgpu numa elf
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
POST_EXCLUDE_REGEXES "system32"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP
|
||||
)
|
||||
|
||||
foreach(HIP_LIB_BIN_INSTALL_DIR IN ITEMS ${HIP_BIN_INSTALL_DIR} ${HIP_LIB_INSTALL_DIR})
|
||||
if(EXISTS ${HIP_LIB_BIN_INSTALL_DIR}/rocblas)
|
||||
install(DIRECTORY ${HIP_LIB_BIN_INSTALL_DIR}/rocblas DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT HIP)
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(NOT APPLE)
|
||||
find_package(Vulkan)
|
||||
if(Vulkan_FOUND)
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/ml/backend/ggml/ggml/src/ggml-vulkan)
|
||||
target_include_directories(ggml-vulkan PRIVATE ${GGML_INCLUDE_DIRS})
|
||||
install(TARGETS ggml-vulkan
|
||||
RUNTIME_DEPENDENCIES
|
||||
PRE_INCLUDE_REGEXES vulkan
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT Vulkan
|
||||
)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
option(MLX_ENGINE "Enable MLX backend" OFF)
|
||||
if(MLX_ENGINE)
|
||||
message(STATUS "Setting up MLX (this takes a while...)")
|
||||
add_subdirectory(${CMAKE_CURRENT_SOURCE_DIR}/x/imagegen/mlx)
|
||||
|
||||
# Find CUDA toolkit if MLX is built with CUDA support
|
||||
find_package(CUDAToolkit)
|
||||
|
||||
# Build list of directories for runtime dependency resolution
|
||||
set(MLX_RUNTIME_DIRS ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR})
|
||||
# Add cuDNN bin paths for DLLs (Windows MLX CUDA builds)
|
||||
# CUDNN_ROOT_DIR is the standard CMake variable for cuDNN location
|
||||
if(DEFINED ENV{CUDNN_ROOT_DIR})
|
||||
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/)
|
||||
file(GLOB CUDNN_BIN_SUBDIRS "$ENV{CUDNN_ROOT_DIR}/bin/*")
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
|
||||
endif()
|
||||
# Add build output directory and MLX dependency build directories
|
||||
list(APPEND MLX_RUNTIME_DIRS ${OLLAMA_BUILD_DIR})
|
||||
# OpenBLAS DLL location (pre-built zip extracts into openblas-src/bin/)
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
|
||||
# NCCL: on Linux, if real NCCL is found, cmake bundles libnccl.so via the
|
||||
# regex below. If NCCL is not found, MLX links a static stub (OBJECT lib)
|
||||
# so there is no runtime dependency. This path covers the stub build dir
|
||||
# for windows so we include the DLL in our dependencies.
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/distributed/nccl/nccl_stub-prefix/src/nccl_stub-build/Release)
|
||||
|
||||
# Base regexes for runtime dependencies (cross-platform)
|
||||
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl openblas gfortran)
|
||||
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer)
|
||||
if(WIN32)
|
||||
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
|
||||
endif()
|
||||
|
||||
install(TARGETS mlx mlxc
|
||||
RUNTIME_DEPENDENCIES
|
||||
DIRECTORIES ${MLX_RUNTIME_DIRS}
|
||||
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
|
||||
if(TARGET jaccl)
|
||||
install(TARGETS jaccl
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
endif()
|
||||
|
||||
# Install the Metal library for macOS arm64 (must be colocated with the binary)
|
||||
# Metal backend is only built for arm64, not x86_64
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install headers for NVRTC JIT compilation at runtime.
|
||||
# MLX's own install rules use the default component so they get skipped by
|
||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||
#
|
||||
# Layout:
|
||||
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ — CCCL headers
|
||||
# ${OLLAMA_INSTALL_DIR}/include/*.h — CUDA toolkit headers
|
||||
#
|
||||
# MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir()[.parent_path()] / "include" / "cccl"
|
||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
|
||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/nv
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
if(NOT WIN32 AND NOT APPLE)
|
||||
install(CODE "
|
||||
set(_link \"${CMAKE_INSTALL_PREFIX}/lib/ollama/include\")
|
||||
set(_target \"${OLLAMA_RUNNER_DIR}/include\")
|
||||
if(NOT EXISTS \${_link})
|
||||
execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink \${_target} \${_link})
|
||||
endif()
|
||||
" COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
|
||||
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
|
||||
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
|
||||
# $CUDA_PATH/include/*.h via NVRTC --include-path.
|
||||
if(CUDAToolkit_FOUND)
|
||||
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
|
||||
# (e.g. ".../include;.../include/cccl"). Find the entry that
|
||||
# contains the CUDA runtime headers we need.
|
||||
set(_cuda_inc "")
|
||||
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
|
||||
if(EXISTS "${_dir}/cuda_runtime_api.h")
|
||||
set(_cuda_inc "${_dir}")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _cuda_inc)
|
||||
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
|
||||
else()
|
||||
set(_dst "${OLLAMA_INSTALL_DIR}/include")
|
||||
set(_MLX_JIT_CUDA_HEADERS
|
||||
builtin_types.h
|
||||
cooperative_groups.h
|
||||
cuda_bf16.h
|
||||
cuda_bf16.hpp
|
||||
cuda_device_runtime_api.h
|
||||
cuda_fp16.h
|
||||
cuda_fp16.hpp
|
||||
cuda_fp8.h
|
||||
cuda_fp8.hpp
|
||||
cuda_runtime_api.h
|
||||
device_types.h
|
||||
driver_types.h
|
||||
math_constants.h
|
||||
surface_types.h
|
||||
texture_types.h
|
||||
vector_functions.h
|
||||
vector_functions.hpp
|
||||
vector_types.h
|
||||
)
|
||||
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
|
||||
install(FILES "${_cuda_inc}/${_hdr}"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX)
|
||||
endforeach()
|
||||
# Subdirectory headers
|
||||
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX
|
||||
FILES_MATCHING PATTERN "*.h")
|
||||
install(FILES "${_cuda_inc}/crt/host_defines.h"
|
||||
DESTINATION "${_dst}/crt"
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation)
|
||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||
# to the wrong destination). We must install it explicitly here.
|
||||
if(WIN32)
|
||||
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Manually install CUDA runtime libraries that MLX loads via dlopen
|
||||
# (not detected by RUNTIME_DEPENDENCIES since they aren't link-time deps)
|
||||
if(CUDAToolkit_FOUND)
|
||||
file(GLOB MLX_CUDA_LIBS
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublasLt.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
|
||||
if(MLX_CUDA_LIBS)
|
||||
install(FILES ${MLX_CUDA_LIBS}
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/llama/server/CMakeLists.txt")
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER TRUE)
|
||||
else()
|
||||
set(OLLAMA_HAVE_LLAMA_SERVER FALSE)
|
||||
endif()
|
||||
include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/local.cmake)
|
||||
+5
-169
@@ -11,109 +11,10 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CPU",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"inherits": [ "Default" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50-virtual;60-virtual;61-virtual;70-virtual;75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v11"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "50;52;60;61;70;75;80;86;89;90;90a;120",
|
||||
"CMAKE_CUDA_FLAGS": "-Wno-deprecated-gpu-targets -t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;87-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"CMAKE_CUDA_FLAGS": "-t 2",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_v13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "72;87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_ARCHITECTURES": "87",
|
||||
"OLLAMA_RUNNER_DIR": "cuda_jetpack6"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"name": "MLX Metal",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_PLATFORM": "amd"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx940;gfx941;gfx942;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_HIP_FLAGS": "-parallel-jobs=4",
|
||||
"AMDGPU_TARGETS": "gfx942;gfx950;gfx1010;gfx1012;gfx1030;gfx1100;gfx1101;gfx1102;gfx1103;gfx1150;gfx1151;gfx1200;gfx1201;gfx908:xnack-;gfx90a:xnack+;gfx90a:xnack-",
|
||||
"OLLAMA_RUNNER_DIR": "rocm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "vulkan"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"inherits": [ "Default" ],
|
||||
"cacheVariables": {
|
||||
"MLX_ENGINE": "ON",
|
||||
"OLLAMA_RUNNER_DIR": "mlx"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"inherits": [ "MLX", "CUDA 12" ],
|
||||
"cacheVariables": {
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v12"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"inherits": [ "MLX", "CUDA 13" ],
|
||||
"cacheVariables": {
|
||||
"MLX_CUDA_ARCHITECTURES": "86;89;90;90a;100;103;75-virtual;80-virtual;110-virtual;120-virtual;121-virtual",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
|
||||
"OLLAMA_MLX_BACKENDS": "metal_v3;metal_v4"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -124,74 +25,9 @@
|
||||
"configuration": "Release"
|
||||
},
|
||||
{
|
||||
"name": "CPU",
|
||||
"configurePreset": "Default",
|
||||
"targets": [ "ggml-cpu" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA",
|
||||
"configurePreset": "CUDA",
|
||||
"targets": [ "ggml-cuda" ]
|
||||
},
|
||||
{
|
||||
"name": "CUDA 11",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 11"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 12",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "CUDA 13",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "CUDA 13"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 5",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 5"
|
||||
},
|
||||
{
|
||||
"name": "JetPack 6",
|
||||
"inherits": [ "CUDA" ],
|
||||
"configurePreset": "JetPack 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm",
|
||||
"configurePreset": "ROCm",
|
||||
"targets": [ "ggml-hip" ]
|
||||
},
|
||||
{
|
||||
"name": "ROCm 6",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 6"
|
||||
},
|
||||
{
|
||||
"name": "ROCm 7",
|
||||
"inherits": [ "ROCm" ],
|
||||
"configurePreset": "ROCm 7"
|
||||
},
|
||||
{
|
||||
"name": "Vulkan",
|
||||
"targets": [ "ggml-vulkan" ],
|
||||
"configurePreset": "Vulkan"
|
||||
},
|
||||
{
|
||||
"name": "MLX",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 12",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 12"
|
||||
},
|
||||
{
|
||||
"name": "MLX CUDA 13",
|
||||
"targets": [ "mlx", "mlxc" ],
|
||||
"configurePreset": "MLX CUDA 13"
|
||||
"name": "MLX Metal",
|
||||
"targets": [ "ollama-mlx-backends" ],
|
||||
"configurePreset": "MLX Metal"
|
||||
}
|
||||
]
|
||||
}
|
||||
+160
-99
@@ -37,113 +37,150 @@ RUN dnf install -y unzip \
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
ENV LDFLAGS=-s
|
||||
|
||||
FROM base AS cpu
|
||||
#
|
||||
# GPU toolchain stages — provide compilers for llama-server GPU builds
|
||||
#
|
||||
|
||||
FROM base AS cpu-deps
|
||||
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CPU' \
|
||||
&& cmake --build --preset 'CPU' -- -l $(nproc) \
|
||||
&& cmake --install build --component CPU --strip
|
||||
|
||||
FROM base AS cuda-11
|
||||
ARG CUDA11VERSION=11.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-11/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 11' \
|
||||
&& cmake --build --preset 'CUDA 11' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS cuda-12
|
||||
FROM base AS cuda-12-deps
|
||||
ARG CUDA12VERSION=12.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA12VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-12/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 12' \
|
||||
&& cmake --build --preset 'CUDA 12' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
|
||||
FROM base AS cuda-13
|
||||
FROM base AS cuda-13-deps
|
||||
ARG CUDA13VERSION=13.0
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 13' \
|
||||
&& cmake --build --preset 'CUDA 13' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS rocm-7-deps
|
||||
ENV PATH=/opt/rocm/llvm/bin:/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:$PATH
|
||||
|
||||
FROM base AS rocm-7
|
||||
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'ROCm 7' \
|
||||
&& cmake --build --preset 'ROCm 7' -- -l $(nproc) \
|
||||
&& cmake --install build --component HIP --strip
|
||||
RUN rm -f dist/lib/ollama/rocm/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 5' \
|
||||
&& cmake --build --preset 'JetPack 5' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 6' \
|
||||
&& cmake --build --preset 'JetPack 6' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS vulkan
|
||||
FROM base AS vulkan-deps
|
||||
ARG VULKANVERSION
|
||||
RUN ln -s /usr/bin/python3 /usr/bin/python \
|
||||
&& wget https://sdk.lunarg.com/sdk/download/${VULKANVERSION}/linux/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz -O /tmp/vulkansdk.tar.xz \
|
||||
&& tar xvf /tmp/vulkansdk.tar.xz -C /tmp \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 vulkan-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 spirv-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 shaderc \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/include/* /usr/local/include/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/lib/* /usr/local/lib \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/share/* /usr/local/share/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/bin/* /usr/local/bin/ \
|
||||
&& rm -rf /tmp/${VULKANVERSION} /tmp/vulkansdk.tar.xz
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
ENV VULKAN_SDK=/usr/local
|
||||
|
||||
#
|
||||
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, or llama/compat/ changes.
|
||||
#
|
||||
# CPU stage: llama-server + ggml-base + ggml-cpu variants → lib/ollama/
|
||||
# GPU stages: GPU backend .so only → lib/ollama/<variant>/
|
||||
#
|
||||
|
||||
FROM cpu-deps AS llama-server-cpu
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'Vulkan' \
|
||||
&& cmake --build --preset 'Vulkan' -- -l $(nproc) \
|
||||
&& cmake --install build --component Vulkan --strip
|
||||
cmake -S llama/server --preset cpu \
|
||||
&& cmake --build build/llama-server-cpu -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cpu --component llama-server --strip \
|
||||
&& for lib in \
|
||||
/usr/lib64/libgomp.so* \
|
||||
/usr/lib64/libomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libgomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libomp.so*; do \
|
||||
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
|
||||
done
|
||||
|
||||
FROM cuda-12-deps AS llama-server-cuda_v12
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v12_linux \
|
||||
&& cmake --build build/llama-server-cuda_v12 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v12 --component llama-server --strip
|
||||
|
||||
FROM cuda-13-deps AS llama-server-cuda_v13
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v13_linux \
|
||||
&& cmake --build build/llama-server-cuda_v13 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v13 --component llama-server --strip
|
||||
|
||||
FROM rocm-7-deps AS llama-server-rocm_v7_2
|
||||
ENV CC=clang CXX=clang++
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset rocm_v7_2_linux \
|
||||
&& cmake --build build/llama-server-rocm_v7_2 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-rocm_v7_2 --component llama-server --strip
|
||||
RUN rm -f dist/lib/ollama/rocm_v7_2/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM vulkan-deps AS llama-server-vulkan
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset vulkan \
|
||||
&& cmake --build build/llama-server-vulkan -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-vulkan --component llama-server --strip
|
||||
|
||||
#
|
||||
# JetPack stages — self-contained with their own base images
|
||||
#
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack5 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack5 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack5 --component llama-server --strip
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack6 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack6 --component llama-server --strip
|
||||
|
||||
#
|
||||
# MLX stage
|
||||
#
|
||||
|
||||
FROM base AS mlx
|
||||
ARG CUDA13VERSION=13.0
|
||||
ARG OLLAMA_MLX_BUILD_JOBS=
|
||||
ARG OLLAMA_MLX_NVCC_THREADS=2
|
||||
ARG MLX_CUDA_RAM_MB=
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-} \
|
||||
&& dnf install -y openblas-devel lapack-devel \
|
||||
&& dnf install -y libcudnn9-cuda-13 libcudnn9-devel-cuda-13 \
|
||||
@@ -154,7 +191,7 @@ ENV LAPACK_INCLUDE_DIRS=/usr/include/openblas
|
||||
ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-linux/lib/stubs"
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
COPY cmake cmake
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
COPY go.mod go.sum .
|
||||
COPY MLX_VERSION MLX_C_VERSION .
|
||||
@@ -170,9 +207,12 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
&& if [ -f /tmp/local-mlx-c/CMakeLists.txt ]; then \
|
||||
export OLLAMA_MLX_C_SOURCE=/tmp/local-mlx-c; \
|
||||
fi \
|
||||
&& cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas \
|
||||
&& cmake --build --preset 'MLX CUDA 13' -- -l $(nproc) \
|
||||
&& cmake --install build --component MLX --strip
|
||||
&& cmake -S . -B build/mlx_cuda_v13 -DOLLAMA_MLX_BACKENDS=cuda_v13 -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas -DCMAKE_CUDA_FLAGS="-t ${OLLAMA_MLX_NVCC_THREADS}" ${MLX_CUDA_RAM_MB:+-DMLX_CUDA_RAM_MB=${MLX_CUDA_RAM_MB}} -DOLLAMA_PAYLOAD_INSTALL_PREFIX=/go/src/github.com/ollama/ollama/dist \
|
||||
&& cmake --build build/mlx_cuda_v13 --target ollama-mlx-cuda_v13 -- -l $(nproc) ${OLLAMA_MLX_BUILD_JOBS:+-j ${OLLAMA_MLX_BUILD_JOBS}}
|
||||
|
||||
#
|
||||
# Go build
|
||||
#
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
@@ -190,38 +230,59 @@ ENV CGO_CXXFLAGS="${CGO_CXXFLAGS}"
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -trimpath -buildmode=pie -o /bin/ollama .
|
||||
|
||||
#
|
||||
# Assembly stages — combine llama-server variants + GPU runtime libs
|
||||
#
|
||||
|
||||
FROM --platform=linux/amd64 scratch AS amd64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=jetpack-5 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=jetpack-6 dist/lib/ollama/ /lib/ollama/
|
||||
|
||||
FROM scratch AS rocm
|
||||
COPY --from=rocm-7 dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama
|
||||
|
||||
FROM ${FLAVOR} AS archive
|
||||
COPY --from=cpu dist/lib/ollama /lib/ollama
|
||||
FROM --platform=linux/amd64 scratch AS amd64-archive
|
||||
COPY --from=amd64 /lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64-archive
|
||||
COPY --from=arm64 /lib/ollama /lib/ollama/
|
||||
|
||||
FROM ${TARGETARCH}-archive AS archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
FROM ${FLAVOR} AS image-archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
FROM ubuntu:24.04
|
||||
ARG APT_MIRROR=http://archive.ubuntu.com/ubuntu
|
||||
RUN sed -i "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" /etc/apt/sources.list.d/ubuntu.sources \
|
||||
ARG APT_PORTS_MIRROR=http://ports.ubuntu.com/ubuntu-ports
|
||||
RUN sed -i \
|
||||
-e "s|http://archive.ubuntu.com/ubuntu|$APT_MIRROR|g" \
|
||||
-e "s|http://ports.ubuntu.com/ubuntu-ports|$APT_PORTS_MIRROR|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y ca-certificates libvulkan1 libopenblas0 \
|
||||
&& sed -i "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" /etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& sed -i \
|
||||
-e "s|$APT_MIRROR|http://archive.ubuntu.com/ubuntu|g" \
|
||||
-e "s|$APT_PORTS_MIRROR|http://ports.ubuntu.com/ubuntu-ports|g" \
|
||||
/etc/apt/sources.list.d/ubuntu.sources \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=archive /bin /usr/bin
|
||||
COPY --from=image-archive /bin /usr/bin
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
COPY --from=archive /lib/ollama /usr/lib/ollama
|
||||
COPY --from=image-archive /lib/ollama /usr/lib/ollama
|
||||
ENV LD_LIBRARY_PATH=/usr/local/nvidia/lib:/usr/local/nvidia/lib64
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=compute,utility
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
b9409
|
||||
+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)
|
||||
+106
-28
@@ -78,6 +78,11 @@ type MessagesRequest struct {
|
||||
ToolChoice *ToolChoice `json:"tool_choice,omitempty"`
|
||||
Thinking *ThinkingConfig `json:"thinking,omitempty"`
|
||||
Metadata *Metadata `json:"metadata,omitempty"`
|
||||
OutputConfig *OutputConfig `json:"output_config,omitempty"`
|
||||
}
|
||||
|
||||
type OutputConfig struct {
|
||||
Effort string `json:"effort,omitempty"`
|
||||
}
|
||||
|
||||
// MessageParam represents a message in the request
|
||||
@@ -161,7 +166,7 @@ type WebSearchToolResultError struct {
|
||||
|
||||
// ImageSource represents the source of an image
|
||||
type ImageSource struct {
|
||||
Type string `json:"type"` // "base64" or "url"
|
||||
Type string `json:"type"` // "base64"
|
||||
MediaType string `json:"media_type,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
@@ -373,9 +378,26 @@ func FromMessagesRequest(r MessagesRequest) (*api.ChatRequest, error) {
|
||||
}
|
||||
|
||||
var think *api.ThinkValue
|
||||
normalizedEffort := ""
|
||||
if r.OutputConfig != nil {
|
||||
normalizedEffort = strings.ToLower(strings.TrimSpace(r.OutputConfig.Effort))
|
||||
if normalizedEffort == "xhigh" {
|
||||
normalizedEffort = "high"
|
||||
}
|
||||
}
|
||||
|
||||
if r.Thinking != nil && r.Thinking.Type == "enabled" {
|
||||
think = &api.ThinkValue{Value: true}
|
||||
}
|
||||
if r.Thinking != nil && r.Thinking.Type == "disabled" {
|
||||
think = &api.ThinkValue{Value: false}
|
||||
}
|
||||
if think == nil && r.OutputConfig != nil {
|
||||
switch normalizedEffort {
|
||||
case "high", "medium", "low", "max":
|
||||
think = &api.ThinkValue{Value: normalizedEffort}
|
||||
}
|
||||
}
|
||||
|
||||
stream := r.Stream
|
||||
convertedRequest := &api.ChatRequest{
|
||||
@@ -425,17 +447,12 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
return nil, errors.New("invalid image source")
|
||||
}
|
||||
|
||||
if block.Source.Type == "base64" {
|
||||
decoded, err := base64.StdEncoding.DecodeString(block.Source.Data)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: invalid base64 image data", "role", role, "error", err)
|
||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||
}
|
||||
images = append(images, decoded)
|
||||
} else {
|
||||
logutil.Trace("anthropic: unsupported image source type", "role", role, "source_type", block.Source.Type)
|
||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", block.Source.Type)
|
||||
decoded, err := resolveImageSource(block.Source)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: unsupported image source", "role", role, "source_type", block.Source.Type, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
images = append(images, decoded)
|
||||
|
||||
case "tool_use":
|
||||
toolUseBlocks++
|
||||
@@ -457,26 +474,16 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
|
||||
case "tool_result":
|
||||
toolResultBlocks++
|
||||
var resultContent string
|
||||
|
||||
switch c := block.Content.(type) {
|
||||
case string:
|
||||
resultContent = c
|
||||
case []any:
|
||||
for _, cb := range c {
|
||||
if cbMap, ok := cb.(map[string]any); ok {
|
||||
if cbMap["type"] == "text" {
|
||||
if text, ok := cbMap["text"].(string); ok {
|
||||
resultContent += text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resultContent, resultImages, err := convertToolResultContent(block.Content)
|
||||
if err != nil {
|
||||
logutil.Trace("anthropic: invalid tool_result content", "role", role, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
toolResults = append(toolResults, api.Message{
|
||||
Role: "tool",
|
||||
Content: resultContent,
|
||||
Images: resultImages,
|
||||
ToolCallID: block.ToolUseID,
|
||||
})
|
||||
|
||||
@@ -508,6 +515,10 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
}
|
||||
}
|
||||
|
||||
if role == "user" && len(toolResults) > 0 {
|
||||
messages = append(messages, toolResults...)
|
||||
}
|
||||
|
||||
if textContent.Len() > 0 || len(images) > 0 || len(toolCalls) > 0 || thinking != "" {
|
||||
m := api.Message{
|
||||
Role: role,
|
||||
@@ -519,8 +530,10 @@ func convertMessage(msg MessageParam) ([]api.Message, error) {
|
||||
messages = append(messages, m)
|
||||
}
|
||||
|
||||
// Add tool results as separate messages
|
||||
messages = append(messages, toolResults...)
|
||||
// Add tool results as separate messages.
|
||||
if role != "user" || len(toolResults) == 0 {
|
||||
messages = append(messages, toolResults...)
|
||||
}
|
||||
logutil.Trace("anthropic: converted block message",
|
||||
"role", role,
|
||||
"blocks", len(msg.Content),
|
||||
@@ -969,6 +982,71 @@ func GenerateMessageID() string {
|
||||
return generateID("msg")
|
||||
}
|
||||
|
||||
func resolveImageSource(source *ImageSource) (api.ImageData, error) {
|
||||
if source.Type != "base64" {
|
||||
return nil, fmt.Errorf("invalid image source type: %s. Only base64 images are supported.", source.Type)
|
||||
}
|
||||
|
||||
decoded, err := base64.StdEncoding.DecodeString(source.Data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid base64 image data: %w", err)
|
||||
}
|
||||
|
||||
return decoded, nil
|
||||
}
|
||||
|
||||
func convertToolResultContent(content any) (string, []api.ImageData, error) {
|
||||
switch c := content.(type) {
|
||||
case nil:
|
||||
return "", nil, nil
|
||||
case string:
|
||||
return c, nil, nil
|
||||
case []any:
|
||||
var text strings.Builder
|
||||
var images []api.ImageData
|
||||
|
||||
for _, cb := range c {
|
||||
cbMap, ok := cb.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch cbMap["type"] {
|
||||
case "text":
|
||||
if t, ok := cbMap["text"].(string); ok {
|
||||
text.WriteString(t)
|
||||
}
|
||||
case "image":
|
||||
rawSource, ok := cbMap["source"].(map[string]any)
|
||||
if !ok {
|
||||
return "", nil, errors.New("invalid tool_result image source")
|
||||
}
|
||||
|
||||
var source ImageSource
|
||||
if rawType, ok := rawSource["type"].(string); ok {
|
||||
source.Type = rawType
|
||||
}
|
||||
if rawMediaType, ok := rawSource["media_type"].(string); ok {
|
||||
source.MediaType = rawMediaType
|
||||
}
|
||||
if rawData, ok := rawSource["data"].(string); ok {
|
||||
source.Data = rawData
|
||||
}
|
||||
|
||||
img, err := resolveImageSource(&source)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
images = append(images, img)
|
||||
}
|
||||
}
|
||||
|
||||
return text.String(), images, nil
|
||||
default:
|
||||
return "", nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ptr returns a pointer to the given string value
|
||||
func ptr(s string) *string {
|
||||
return &s
|
||||
|
||||
@@ -271,6 +271,241 @@ func TestFromMessagesRequest_WithToolResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithToolResultImage(t *testing.T) {
|
||||
imgData, _ := base64.StdEncoding.DecodeString(testImage)
|
||||
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: "call_img",
|
||||
Content: []any{
|
||||
map[string]any{"type": "text", "text": "Attached image"},
|
||||
map[string]any{
|
||||
"type": "image",
|
||||
"source": map[string]any{
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": testImage,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Messages) != 1 {
|
||||
t.Fatalf("expected 1 message, got %d", len(result.Messages))
|
||||
}
|
||||
|
||||
msg := result.Messages[0]
|
||||
if msg.Role != "tool" {
|
||||
t.Errorf("expected role 'tool', got %q", msg.Role)
|
||||
}
|
||||
if msg.ToolCallID != "call_img" {
|
||||
t.Errorf("expected tool_call_id 'call_img', got %q", msg.ToolCallID)
|
||||
}
|
||||
if msg.Content != "Attached image" {
|
||||
t.Errorf("unexpected content: %q", msg.Content)
|
||||
}
|
||||
if len(msg.Images) != 1 {
|
||||
t.Fatalf("expected 1 image, got %d", len(msg.Images))
|
||||
}
|
||||
if string(msg.Images[0]) != string(imgData) {
|
||||
t.Error("image data mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithToolResultFollowedByUserText(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
MaxTokens: 1024,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "assistant",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_use",
|
||||
ID: "call_read",
|
||||
Name: "Read",
|
||||
Input: makeArgs("file_path", "/Users/hoyyeva/Desktop/aaa.png"),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Role: "user",
|
||||
Content: []ContentBlock{
|
||||
{
|
||||
Type: "tool_result",
|
||||
ToolUseID: "call_read",
|
||||
Content: "Read image (311.5KB)",
|
||||
},
|
||||
{
|
||||
Type: "text",
|
||||
Text: ptr("Please describe it."),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(result.Messages) != 3 {
|
||||
t.Fatalf("expected 3 messages, got %d", len(result.Messages))
|
||||
}
|
||||
|
||||
if result.Messages[1].Role != "tool" {
|
||||
t.Fatalf("expected second message to be tool, got %q", result.Messages[1].Role)
|
||||
}
|
||||
if result.Messages[1].ToolCallID != "call_read" {
|
||||
t.Fatalf("expected tool_call_id 'call_read', got %q", result.Messages[1].ToolCallID)
|
||||
}
|
||||
if result.Messages[2].Role != "user" {
|
||||
t.Fatalf("expected third message to be user, got %q", result.Messages[2].Role)
|
||||
}
|
||||
if result.Messages[2].Content != "Please describe it." {
|
||||
t.Fatalf("unexpected user content: %q", result.Messages[2].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithOutputConfigEffort(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "gemma4",
|
||||
MaxTokens: 32000,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: textContent("Describe the image."),
|
||||
},
|
||||
},
|
||||
OutputConfig: &OutputConfig{
|
||||
Effort: "high",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Think == nil {
|
||||
t.Fatal("expected think to be set from output_config.effort")
|
||||
}
|
||||
|
||||
if got := result.Think.String(); got != "high" {
|
||||
t.Fatalf("expected think level 'high', got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithOutputConfigEffortXHighMapsToHigh(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "gemma4",
|
||||
MaxTokens: 32000,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: textContent("Describe the image."),
|
||||
},
|
||||
},
|
||||
OutputConfig: &OutputConfig{
|
||||
Effort: "xhigh",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Think == nil {
|
||||
t.Fatal("expected think to be set from output_config.effort")
|
||||
}
|
||||
|
||||
if got := result.Think.String(); got != "high" {
|
||||
t.Fatalf("expected think level 'high' for xhigh effort, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_ThinkingDisabledOverridesOutputConfigEffort(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "gemma4",
|
||||
MaxTokens: 32000,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: textContent("Describe the image."),
|
||||
},
|
||||
},
|
||||
Thinking: &ThinkingConfig{
|
||||
Type: "disabled",
|
||||
},
|
||||
OutputConfig: &OutputConfig{
|
||||
Effort: "high",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Think == nil {
|
||||
t.Fatal("expected think to be set")
|
||||
}
|
||||
|
||||
if got := result.Think.Value; got != false {
|
||||
t.Fatalf("expected think=false when thinking is disabled, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_ThinkingAdaptiveUsesOutputConfigEffort(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "gemma4",
|
||||
MaxTokens: 32000,
|
||||
Messages: []MessageParam{
|
||||
{
|
||||
Role: "user",
|
||||
Content: textContent("Describe the image."),
|
||||
},
|
||||
},
|
||||
Thinking: &ThinkingConfig{
|
||||
Type: "adaptive",
|
||||
},
|
||||
OutputConfig: &OutputConfig{
|
||||
Effort: "high",
|
||||
},
|
||||
}
|
||||
|
||||
result, err := FromMessagesRequest(req)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result.Think == nil {
|
||||
t.Fatal("expected think to be set from output_config.effort")
|
||||
}
|
||||
|
||||
if got := result.Think.String(); got != "high" {
|
||||
t.Fatalf("expected think level 'high' for adaptive thinking, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromMessagesRequest_WithTools(t *testing.T) {
|
||||
req := MessagesRequest{
|
||||
Model: "test-model",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+53
-25
@@ -600,12 +600,13 @@ type Options struct {
|
||||
|
||||
// Runner options which must be set when the model is loaded into memory
|
||||
type Runner struct {
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
NumCtx int `json:"num_ctx,omitempty"`
|
||||
NumBatch int `json:"num_batch,omitempty"`
|
||||
NumGPU int `json:"num_gpu,omitempty"`
|
||||
MainGPU *int `json:"main_gpu,omitempty"`
|
||||
UseMMap *bool `json:"use_mmap,omitempty"`
|
||||
NumThread int `json:"num_thread,omitempty"`
|
||||
DraftNumPredict int `json:"draft_num_predict,omitempty"`
|
||||
}
|
||||
|
||||
// EmbedRequest is the request passed to [Client.Embed].
|
||||
@@ -672,6 +673,9 @@ type CreateRequest struct {
|
||||
// Quantize is the quantization format for the model; leave blank to not change the quantization level.
|
||||
Quantize string `json:"quantize,omitempty"`
|
||||
|
||||
// DraftQuantize is the quantization format for the draft model.
|
||||
DraftQuantize string `json:"draft_quantize,omitempty"`
|
||||
|
||||
// From is the name of the model or file to use as the source.
|
||||
From string `json:"from,omitempty"`
|
||||
|
||||
@@ -681,6 +685,9 @@ type CreateRequest struct {
|
||||
// Files is a map of files include when creating the model.
|
||||
Files map[string]string `json:"files,omitempty"`
|
||||
|
||||
// DraftFiles is a map of draft model files to include when creating the model.
|
||||
DraftFiles map[string]string `json:"draft_files,omitempty"`
|
||||
|
||||
// Adapters is a map of LoRA adapters to include when creating the model.
|
||||
Adapters map[string]string `json:"adapters,omitempty"`
|
||||
|
||||
@@ -824,14 +831,15 @@ type ProcessResponse struct {
|
||||
|
||||
// ListModelResponse is a single model description in [ListResponse].
|
||||
type ListModelResponse struct {
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
RemoteModel string `json:"remote_model,omitempty"`
|
||||
RemoteHost string `json:"remote_host,omitempty"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
Size int64 `json:"size"`
|
||||
Digest string `json:"digest"`
|
||||
Details ModelDetails `json:"details,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
RemoteModel string `json:"remote_model,omitempty"`
|
||||
RemoteHost string `json:"remote_host,omitempty"`
|
||||
ModifiedAt time.Time `json:"modified_at"`
|
||||
Size int64 `json:"size"`
|
||||
Digest string `json:"digest"`
|
||||
Details ModelDetails `json:"details,omitempty"`
|
||||
Capabilities []model.Capability `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessModelResponse is a single model description in [ProcessResponse].
|
||||
@@ -924,6 +932,8 @@ type ModelDetails struct {
|
||||
Families []string `json:"families"`
|
||||
ParameterSize string `json:"parameter_size"`
|
||||
QuantizationLevel string `json:"quantization_level"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
EmbeddingLength int `json:"embedding_length,omitempty"`
|
||||
}
|
||||
|
||||
// UserResponse provides information about a user.
|
||||
@@ -1046,14 +1056,25 @@ func (opts *Options) FromMap(m map[string]any) error {
|
||||
}
|
||||
field.Set(reflect.ValueOf(slice))
|
||||
case reflect.Pointer:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
val, ok := val.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("option %q must be of type boolean", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&val))
|
||||
} else {
|
||||
case reflect.Int:
|
||||
var i int
|
||||
switch t := val.(type) {
|
||||
case int64:
|
||||
i = int(t)
|
||||
case float64:
|
||||
i = int(t)
|
||||
default:
|
||||
return fmt.Errorf("option %q must be of type integer", key)
|
||||
}
|
||||
field.Set(reflect.ValueOf(&i))
|
||||
default:
|
||||
return fmt.Errorf("unknown type loading config params: %v %v", field.Kind(), field.Type())
|
||||
}
|
||||
default:
|
||||
@@ -1086,11 +1107,12 @@ func DefaultOptions() Options {
|
||||
|
||||
Runner: Runner{
|
||||
// options set when the model is loaded
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
UseMMap: nil,
|
||||
NumCtx: int(envconfig.ContextLength()),
|
||||
NumBatch: 512,
|
||||
NumGPU: -1, // -1 here indicates that NumGPU should be set dynamically
|
||||
NumThread: 0, // let the runtime decide
|
||||
DraftNumPredict: 4,
|
||||
UseMMap: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1294,14 +1316,20 @@ func FormatParams(params map[string][]string) (map[string]any, error) {
|
||||
// TODO: only string slices are supported right now
|
||||
out[key] = vals
|
||||
case reflect.Pointer:
|
||||
var b bool
|
||||
if field.Type() == reflect.TypeOf(&b) {
|
||||
switch field.Type().Elem().Kind() {
|
||||
case reflect.Bool:
|
||||
boolVal, err := strconv.ParseBool(vals[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid bool value %s", vals)
|
||||
}
|
||||
out[key] = &boolVal
|
||||
} else {
|
||||
case reflect.Int:
|
||||
intVal, err := strconv.ParseInt(vals[0], 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid int value %s", vals)
|
||||
}
|
||||
out[key] = intVal
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown type %s for %s", field.Kind(), key)
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -20,6 +20,10 @@ func testPropsMap(m map[string]ToolProperty) *ToolPropertiesMap {
|
||||
return props
|
||||
}
|
||||
|
||||
func testIntPtr(v int) *int {
|
||||
return &v
|
||||
}
|
||||
|
||||
// testArgs creates ToolCallFunctionArguments from a map (convenience function for tests, order not preserved)
|
||||
func testArgs(m map[string]any) ToolCallFunctionArguments {
|
||||
args := NewToolCallFunctionArguments()
|
||||
@@ -168,6 +172,47 @@ func TestUseMmapParsingFromJSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUParsingFromJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
req string
|
||||
wantGPU *int
|
||||
}{
|
||||
{
|
||||
name: "Undefined",
|
||||
req: `{}`,
|
||||
},
|
||||
{
|
||||
name: "Zero",
|
||||
req: `{ "main_gpu": 0 }`,
|
||||
wantGPU: testIntPtr(0),
|
||||
},
|
||||
{
|
||||
name: "Nonzero",
|
||||
req: `{ "main_gpu": 1 }`,
|
||||
wantGPU: testIntPtr(1),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var oMap map[string]any
|
||||
err := json.Unmarshal([]byte(test.req), &oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := DefaultOptions()
|
||||
err = opts.FromMap(oMap)
|
||||
require.NoError(t, err)
|
||||
|
||||
if test.wantGPU == nil {
|
||||
assert.Nil(t, opts.MainGPU)
|
||||
} else if assert.NotNil(t, opts.MainGPU) {
|
||||
assert.Equal(t, *test.wantGPU, *opts.MainGPU)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUseMmapFormatParams(t *testing.T) {
|
||||
tr := true
|
||||
fa := false
|
||||
@@ -232,6 +277,12 @@ func TestUseMmapFormatParams(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainGPUFormatParams(t *testing.T) {
|
||||
resp, err := FormatParams(map[string][]string{"main_gpu": {"0"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), resp["main_gpu"])
|
||||
}
|
||||
|
||||
func TestMessage_UnmarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
|
||||
+4
-49
@@ -90,9 +90,8 @@ DialogFontSize=12
|
||||
[Files]
|
||||
#if FileExists("..\dist\windows-ollama-app-amd64.exe")
|
||||
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
|
||||
Source: "..\dist\windows-amd64\vc_redist.x64.exe"; DestDir: "{tmp}"; Check: not IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; Excludes: "\mlx_*\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
; For local development, rely on binary compatibility at runtime since we can't cross compile
|
||||
@@ -103,9 +102,11 @@ Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#M
|
||||
#endif
|
||||
|
||||
#if FileExists("..\dist\windows-arm64\ollama.exe")
|
||||
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-arm64\lib\ollama")
|
||||
Source: "..\dist\windows-arm64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
@@ -118,12 +119,6 @@ Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFile
|
||||
Type: files; Name: "{%LOCALAPPDATA}\Ollama\updates"
|
||||
|
||||
[Run]
|
||||
#if DirExists("..\dist\windows-arm64")
|
||||
Filename: "{tmp}\vc_redist.arm64.exe"; Parameters: "/install /passive /norestart"; Check: IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-amd64")
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /passive /norestart"; Check: not IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden
|
||||
|
||||
[UninstallRun]
|
||||
@@ -184,46 +179,6 @@ begin
|
||||
Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0;
|
||||
end;
|
||||
|
||||
{ --- VC Runtime libraries discovery code - Only install vc_redist if it isn't already installed ----- }
|
||||
const VCRTL_MIN_V1 = 14;
|
||||
const VCRTL_MIN_V2 = 40;
|
||||
const VCRTL_MIN_V3 = 33807;
|
||||
const VCRTL_MIN_V4 = 0;
|
||||
|
||||
// check if the minimum required vc redist is installed (by looking the registry)
|
||||
function vc_redist_needed (): Boolean;
|
||||
var
|
||||
sRegKey: string;
|
||||
v1: Cardinal;
|
||||
v2: Cardinal;
|
||||
v3: Cardinal;
|
||||
v4: Cardinal;
|
||||
begin
|
||||
if (IsArm64()) then begin
|
||||
sRegKey := 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64';
|
||||
end else begin
|
||||
sRegKey := 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64';
|
||||
end;
|
||||
if (RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Major', v1) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'RBld', v4)) then
|
||||
begin
|
||||
Log ('VC Redist version: ' + IntToStr (v1) +
|
||||
'.' + IntToStr (v2) + '.' + IntToStr (v3) +
|
||||
'.' + IntToStr (v4));
|
||||
{ Version info was found. Return true if later or equal to our
|
||||
minimal required version RTL_MIN_Vx }
|
||||
Result := not (
|
||||
(v1 > VCRTL_MIN_V1) or ((v1 = VCRTL_MIN_V1) and
|
||||
((v2 > VCRTL_MIN_V2) or ((v2 = VCRTL_MIN_V2) and
|
||||
((v3 > VCRTL_MIN_V3) or ((v3 = VCRTL_MIN_V3) and
|
||||
(v4 >= VCRTL_MIN_V4)))))));
|
||||
end
|
||||
else
|
||||
Result := TRUE;
|
||||
end;
|
||||
|
||||
function GetDirSize(Path: String): Int64;
|
||||
var
|
||||
FindRec: TFindRec;
|
||||
|
||||
+10
-9
@@ -1201,15 +1201,16 @@ func (db *database) getSettings() (Settings, error) {
|
||||
func (db *database) setSettings(s Settings) error {
|
||||
lastHomeView := strings.ToLower(strings.TrimSpace(s.LastHomeView))
|
||||
validLaunchView := map[string]struct{}{
|
||||
"launch": {},
|
||||
"openclaw": {},
|
||||
"claude": {},
|
||||
"hermes": {},
|
||||
"codex": {},
|
||||
"copilot": {},
|
||||
"opencode": {},
|
||||
"droid": {},
|
||||
"pi": {},
|
||||
"launch": {},
|
||||
"openclaw": {},
|
||||
"claude": {},
|
||||
"hermes": {},
|
||||
"codex": {},
|
||||
"codex-app": {},
|
||||
"copilot": {},
|
||||
"opencode": {},
|
||||
"droid": {},
|
||||
"pi": {},
|
||||
}
|
||||
if lastHomeView != "chat" {
|
||||
if _, ok := validLaunchView[lastHomeView]; !ok {
|
||||
|
||||
@@ -122,6 +122,21 @@ func TestStore(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings codex app home view is accepted", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "codex-app"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "codex-app" {
|
||||
t.Fatalf("expected codex-app LastHomeView to be preserved, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("window size", func(t *testing.T) {
|
||||
if err := s.SetWindowSize(1024, 768); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -22,11 +22,12 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
command: "ollama launch openclaw",
|
||||
description: "Personal AI with 100+ skills",
|
||||
icon: "/launch-icons/openclaw.svg",
|
||||
id: "codex-app",
|
||||
name: "Codex App",
|
||||
command: "ollama launch codex-app",
|
||||
description: "An AI agent you can delegate real work to, by OpenAI",
|
||||
icon: "/launch-icons/codex-app.png",
|
||||
iconClassName: "h-full w-full",
|
||||
},
|
||||
{
|
||||
id: "hermes",
|
||||
@@ -36,6 +37,13 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||
icon: "/launch-icons/hermes-agent.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
command: "ollama launch openclaw",
|
||||
description: "Personal AI with 100+ skills",
|
||||
icon: "/launch-icons/openclaw.svg",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
|
||||
+73
-14
@@ -5,6 +5,8 @@ package updater
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -169,22 +171,20 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking update: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
etag := strings.Trim(resp.Header.Get("etag"), "\"")
|
||||
if etag == "" {
|
||||
slog.Debug("no etag detected, falling back to filename based dedup")
|
||||
etag = "_"
|
||||
}
|
||||
filename := Installer
|
||||
_, params, err := mime.ParseMediaType(resp.Header.Get("content-disposition"))
|
||||
if err == nil {
|
||||
if err == nil && params["filename"] != "" {
|
||||
filename = params["filename"]
|
||||
}
|
||||
|
||||
stageFilename := filepath.Join(UpdateStageDir, etag, filename)
|
||||
stageFilename, err := updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check to see if we already have it downloaded
|
||||
_, err = os.Stat(stageFilename)
|
||||
@@ -202,13 +202,14 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
return fmt.Errorf("error checking update: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
etag = strings.Trim(resp.Header.Get("etag"), "\"")
|
||||
if etag == "" {
|
||||
slog.Debug("no etag detected, falling back to filename based dedup") // TODO probably can get rid of this redundant log
|
||||
etag = "_"
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("unexpected status attempting to download update %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
stageFilename = filepath.Join(UpdateStageDir, etag, filename)
|
||||
stageFilename, err = updateStagePath(UpdateStageDir, resp.Header.Get("etag"), filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Dir(stageFilename))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -225,10 +226,13 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
if err != nil {
|
||||
return fmt.Errorf("write payload %s: %w", stageFilename, err)
|
||||
}
|
||||
defer fp.Close()
|
||||
if n, err := fp.Write(payload); err != nil || n != len(payload) {
|
||||
_ = fp.Close()
|
||||
return fmt.Errorf("write payload %s: %d vs %d -- %w", stageFilename, n, len(payload), err)
|
||||
}
|
||||
if err := fp.Close(); err != nil {
|
||||
return fmt.Errorf("close payload %s: %w", stageFilename, err)
|
||||
}
|
||||
slog.Info("new update downloaded " + stageFilename)
|
||||
|
||||
if err := VerifyDownload(); err != nil {
|
||||
@@ -239,6 +243,61 @@ func (u *Updater) DownloadNewRelease(ctx context.Context, updateResp UpdateRespo
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateStagePath(stageDir, etag, filename string) (string, error) {
|
||||
filename, err := safeUpdateFilename(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
stageDir, err = filepath.Abs(stageDir)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve update stage dir: %w", err)
|
||||
}
|
||||
|
||||
stageFilename := filepath.Join(stageDir, updateStageETagDir(etag), filename)
|
||||
if err := ensurePathInDir(stageDir, stageFilename); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return stageFilename, nil
|
||||
}
|
||||
|
||||
func safeUpdateFilename(filename string) (string, error) {
|
||||
filename = strings.TrimSpace(filename)
|
||||
if filename == "" {
|
||||
return "", errors.New("missing update filename")
|
||||
}
|
||||
if filename == "." || filename == ".." ||
|
||||
filepath.IsAbs(filename) || path.IsAbs(filename) ||
|
||||
strings.ContainsAny(filename, `/\:`) ||
|
||||
filepath.Base(filename) != filename || path.Base(filename) != filename {
|
||||
return "", fmt.Errorf("unsafe update filename %q", filename)
|
||||
}
|
||||
return filename, nil
|
||||
}
|
||||
|
||||
func updateStageETagDir(etag string) string {
|
||||
etag = strings.Trim(strings.TrimSpace(etag), "\"")
|
||||
if etag == "" {
|
||||
slog.Debug("no etag detected, falling back to filename based dedup")
|
||||
return "_"
|
||||
}
|
||||
|
||||
sum := sha256.Sum256([]byte(etag))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func ensurePathInDir(dir, name string) error {
|
||||
rel, err := filepath.Rel(dir, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve update staging path: %w", err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return fmt.Errorf("update staging path escapes stage dir: %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanupOldDownloads(stageDir string) {
|
||||
files, err := os.ReadDir(stageDir)
|
||||
if err != nil && errors.Is(err, os.ErrNotExist) {
|
||||
|
||||
@@ -22,6 +22,15 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
const updateArchiveRoot = "Ollama.app"
|
||||
|
||||
type bundleEntryScope int
|
||||
|
||||
const (
|
||||
bundleEntryRelative bundleEntryScope = iota
|
||||
bundleEntryWithArchiveRoot
|
||||
)
|
||||
|
||||
var (
|
||||
appBackupDir string
|
||||
SystemWidePath = "/Applications/Ollama.app"
|
||||
@@ -167,8 +176,12 @@ func DoUpgrade(interactive bool) error {
|
||||
}
|
||||
name := s[1]
|
||||
if strings.HasSuffix(name, "/") {
|
||||
d := filepath.Join(BundlePath, name)
|
||||
err := os.MkdirAll(d, 0o755)
|
||||
d, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return err
|
||||
}
|
||||
err = os.MkdirAll(d, 0o755)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
@@ -181,30 +194,14 @@ func DoUpgrade(interactive bool) error {
|
||||
continue
|
||||
}
|
||||
|
||||
src, err := f.Open()
|
||||
destName, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("failed to open bundle file %s: %w", name, err)
|
||||
return err
|
||||
}
|
||||
destName := filepath.Join(BundlePath, name)
|
||||
// Verify directory first
|
||||
d := filepath.Dir(destName)
|
||||
if _, err := os.Stat(d); err != nil {
|
||||
err := os.MkdirAll(d, 0o755)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
if err := extractBundleFile(f, destName, name); err != nil {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("failed to open output file %s: %w", destName, err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
if _, err := io.Copy(destFile, src); err != nil {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, f := range links {
|
||||
@@ -225,16 +222,24 @@ func DoUpgrade(interactive bool) error {
|
||||
return err
|
||||
}
|
||||
link := string(buf)
|
||||
if link[0] == '/' {
|
||||
if link == "" {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("bundle contains empty symlink %s", f.Name)
|
||||
}
|
||||
if filepath.IsAbs(link) {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("bundle contains absolute symlink %s -> %s", f.Name, link)
|
||||
}
|
||||
// Don't allow links outside of Ollama.app
|
||||
if strings.HasPrefix(filepath.Join(filepath.Dir(name), link), "..") {
|
||||
if !validBundleLinkTarget(name, link, bundleEntryRelative) {
|
||||
anyFailures = true
|
||||
return fmt.Errorf("bundle contains link outside of contents %s -> %s", f.Name, link)
|
||||
return fmt.Errorf("bundle contains invalid symlink %s -> %s", f.Name, link)
|
||||
}
|
||||
if err = os.Symlink(link, filepath.Join(BundlePath, name)); err != nil {
|
||||
destName, err := bundleEntryPath(BundlePath, name, bundleEntryRelative)
|
||||
if err != nil {
|
||||
anyFailures = true
|
||||
return err
|
||||
}
|
||||
if err = os.Symlink(link, destName); err != nil {
|
||||
anyFailures = true
|
||||
return err
|
||||
}
|
||||
@@ -282,8 +287,11 @@ func verifyDownload() error {
|
||||
links := []*zip.File{}
|
||||
for _, f := range r.File {
|
||||
if strings.HasSuffix(f.Name, "/") {
|
||||
d := filepath.Join(dir, f.Name)
|
||||
err := os.MkdirAll(d, 0o755)
|
||||
d, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.MkdirAll(d, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
}
|
||||
@@ -294,26 +302,12 @@ func verifyDownload() error {
|
||||
links = append(links, f)
|
||||
continue
|
||||
}
|
||||
src, err := f.Open()
|
||||
destName, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open bundle file %s: %w", f.Name, err)
|
||||
return err
|
||||
}
|
||||
destName := filepath.Join(dir, f.Name)
|
||||
// Verify directory first
|
||||
d := filepath.Dir(destName)
|
||||
if _, err := os.Stat(d); err != nil {
|
||||
err := os.MkdirAll(d, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open output file %s: %w", destName, err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
if _, err := io.Copy(destFile, src); err != nil {
|
||||
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
|
||||
if err := extractBundleFile(f, destName, f.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, f := range links {
|
||||
@@ -326,13 +320,20 @@ func verifyDownload() error {
|
||||
return err
|
||||
}
|
||||
link := string(buf)
|
||||
if link[0] == '/' {
|
||||
if link == "" {
|
||||
return fmt.Errorf("bundle contains empty symlink %s", f.Name)
|
||||
}
|
||||
if filepath.IsAbs(link) {
|
||||
return fmt.Errorf("bundle contains absolute symlink %s -> %s", f.Name, link)
|
||||
}
|
||||
if strings.HasPrefix(filepath.Join(filepath.Dir(f.Name), link), "..") {
|
||||
return fmt.Errorf("bundle contains link outside of contents %s -> %s", f.Name, link)
|
||||
if !validBundleLinkTarget(f.Name, link, bundleEntryWithArchiveRoot) {
|
||||
return fmt.Errorf("bundle contains invalid symlink %s -> %s", f.Name, link)
|
||||
}
|
||||
if err = os.Symlink(link, filepath.Join(dir, f.Name)); err != nil {
|
||||
destName, err := bundleEntryPath(dir, f.Name, bundleEntryWithArchiveRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = os.Symlink(link, destName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -343,6 +344,53 @@ func verifyDownload() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func bundleEntryPath(root, name string, scope bundleEntryScope) (string, error) {
|
||||
cleanName := filepath.Clean(filepath.FromSlash(name))
|
||||
if !filepath.IsLocal(cleanName) {
|
||||
return "", fmt.Errorf("bundle contains invalid path: %s", name)
|
||||
}
|
||||
if scope == bundleEntryWithArchiveRoot && cleanName != updateArchiveRoot &&
|
||||
!strings.HasPrefix(cleanName, updateArchiveRoot+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("bundle contains invalid path: %s", name)
|
||||
}
|
||||
return filepath.Join(root, cleanName), nil
|
||||
}
|
||||
|
||||
func extractBundleFile(f *zip.File, destName, name string) error {
|
||||
src, err := f.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open bundle file %s: %w", name, err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
d := filepath.Dir(destName)
|
||||
if _, err := os.Stat(d); err != nil {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
return fmt.Errorf("failed to mkdir %s: %w", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
destFile, err := os.OpenFile(destName, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o755)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open output file %s: %w", destName, err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
if _, err := io.Copy(destFile, src); err != nil {
|
||||
return fmt.Errorf("failed to open extract file %s: %w", destName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validBundleLinkTarget(name, link string, scope bundleEntryScope) bool {
|
||||
cleanTarget := filepath.Clean(filepath.Join(filepath.Dir(filepath.FromSlash(name)), filepath.FromSlash(link)))
|
||||
if !filepath.IsLocal(cleanTarget) {
|
||||
return false
|
||||
}
|
||||
return scope == bundleEntryRelative || cleanTarget == updateArchiveRoot ||
|
||||
strings.HasPrefix(cleanTarget, updateArchiveRoot+string(os.PathSeparator))
|
||||
}
|
||||
|
||||
// If we detect an upgrade bundle, attempt to upgrade at startup
|
||||
func DoUpgradeAtStartup() error {
|
||||
bundle := getStagedUpdate()
|
||||
|
||||
@@ -2,6 +2,7 @@ package updater
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -146,6 +147,46 @@ func TestDoUpgrade(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoUpgradeRejectsInvalidBundlePath(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
BundlePath = filepath.Join(tmpDir, "Ollama.app")
|
||||
appBackupDir = filepath.Join(tmpDir, "backup")
|
||||
UpdateStageDir = filepath.Join(tmpDir, "updates")
|
||||
UpgradeMarkerFile = filepath.Join(tmpDir, "upgraded")
|
||||
bundle := filepath.Join(UpdateStageDir, "foo", "ollama-darwin.zip")
|
||||
invalidTarget := filepath.Join(tmpDir, "invalid-entry")
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(BundlePath, "Contents", "MacOS"), 0o755); err != nil {
|
||||
t.Fatal("failed to create empty dirs")
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama"), []byte("old app"), 0o755); err != nil {
|
||||
t.Fatal("failed to create old app")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
|
||||
t.Fatal("failed to create empty dirs")
|
||||
}
|
||||
if err := zipCreationHelper(bundle, []testPayload{{
|
||||
Name: "Ollama.app/../invalid-entry",
|
||||
Body: []byte("payload"),
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := DoUpgrade(false); err == nil {
|
||||
t.Fatal("expected failure with invalid bundle path")
|
||||
} else if !strings.Contains(err.Error(), "bundle contains invalid path") {
|
||||
t.Fatalf("unexpected error with invalid bundle path: %s", err)
|
||||
}
|
||||
if _, err := os.Stat(invalidTarget); err == nil {
|
||||
t.Fatalf("invalid bundle path wrote %s", invalidTarget)
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unexpected stat error for %s: %s", invalidTarget, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(BundlePath, "Contents", "MacOS", "Ollama")); err != nil {
|
||||
t.Fatalf("old app was not restored: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoUpgradeAtStartup(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
BundlePath = filepath.Join(tmpDir, "Ollama.app")
|
||||
@@ -203,7 +244,7 @@ func TestVerifyDownloadFailures(t *testing.T) {
|
||||
in []testPayload
|
||||
expected string
|
||||
}{
|
||||
{"breakout", []testPayload{
|
||||
{"invalid symlink target", []testPayload{
|
||||
{
|
||||
Name: "Ollama.app/",
|
||||
Body: []byte{},
|
||||
@@ -212,15 +253,34 @@ func TestVerifyDownloadFailures(t *testing.T) {
|
||||
Body: []byte("cli payload here"),
|
||||
}, {
|
||||
Name: "Ollama.app/Contents/MacOS/Ollama",
|
||||
Body: []byte("../../../../breakout"),
|
||||
Body: []byte("../../../../invalid-target"),
|
||||
Mode: os.ModeSymlink,
|
||||
},
|
||||
}, "bundle contains link outside"},
|
||||
}, "bundle contains invalid symlink"},
|
||||
{"invalid archive symlink target", []testPayload{
|
||||
{
|
||||
Name: "Ollama.app/Contents/MacOS/Ollama",
|
||||
Body: []byte("../../../invalid-target"),
|
||||
Mode: os.ModeSymlink,
|
||||
},
|
||||
}, "bundle contains invalid symlink"},
|
||||
{"absolute", []testPayload{{
|
||||
Name: "Ollama.app/Contents/MacOS/Ollama",
|
||||
Body: []byte("/etc/foo"),
|
||||
Mode: os.ModeSymlink,
|
||||
}}, "bundle contains absolute"},
|
||||
{"invalid relative file", []testPayload{{
|
||||
Name: "Ollama.app/../invalid-entry",
|
||||
Body: []byte("payload"),
|
||||
}}, "bundle contains invalid path"},
|
||||
{"invalid relative directory", []testPayload{{
|
||||
Name: "Ollama.app/../invalid-entry/",
|
||||
Body: []byte{},
|
||||
}}, "bundle contains invalid path"},
|
||||
{"absolute file", []testPayload{{
|
||||
Name: filepath.Join(tmpDir, "invalid-entry"),
|
||||
Body: []byte("payload"),
|
||||
}}, "bundle contains invalid path"},
|
||||
{"missing", []testPayload{{
|
||||
Name: "Ollama.app/Contents/MacOS/Ollama",
|
||||
Body: []byte("../nothere"),
|
||||
@@ -242,6 +302,11 @@ func TestVerifyDownloadFailures(t *testing.T) {
|
||||
if err == nil || !strings.Contains(err.Error(), tt.expected) {
|
||||
t.Fatalf("expected \"%s\" got %s", tt.expected, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(tmpDir, "invalid-entry")); err == nil {
|
||||
t.Fatal("invalid bundle path wrote unexpected file")
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("unexpected stat error for invalid file: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//go:build (windows || darwin) && updater_live
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/app/store"
|
||||
"github.com/ollama/ollama/app/version"
|
||||
)
|
||||
|
||||
// TestLiveAppUpdate exercises the production update endpoint and downloads the
|
||||
// current OS update artifact. It is intentionally excluded from normal test
|
||||
// runs because it depends on ollama.com and downloads a release artifact.
|
||||
//
|
||||
// Run with:
|
||||
//
|
||||
// go test -tags updater_live -run TestLiveAppUpdate ./app/updater
|
||||
func TestLiveAppUpdate(t *testing.T) {
|
||||
const spoofedVersion = "0.20.0"
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
oldUpdateStageDir := UpdateStageDir
|
||||
oldUpdateDownloaded := UpdateDownloaded
|
||||
oldVerifyDownload := VerifyDownload
|
||||
oldVersion := version.Version
|
||||
defer func() {
|
||||
UpdateStageDir = oldUpdateStageDir
|
||||
UpdateDownloaded = oldUpdateDownloaded
|
||||
VerifyDownload = oldVerifyDownload
|
||||
version.Version = oldVersion
|
||||
}()
|
||||
|
||||
version.Version = spoofedVersion
|
||||
|
||||
expectedFilename := ""
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
t.Setenv("LOCALAPPDATA", t.TempDir())
|
||||
expectedFilename = "OllamaSetup.exe"
|
||||
case "darwin":
|
||||
expectedFilename = "Ollama-darwin.zip"
|
||||
default:
|
||||
t.Fatalf("unsupported updater live test OS %q", runtime.GOOS)
|
||||
}
|
||||
|
||||
UpdateStageDir = filepath.Join(t.TempDir(), "updates")
|
||||
UpdateDownloaded = false
|
||||
verifyCalled := false
|
||||
VerifyDownload = func() error {
|
||||
verifyCalled = true
|
||||
return verifyDownload()
|
||||
}
|
||||
|
||||
updater := &Updater{Store: &store.Store{DBPath: filepath.Join(t.TempDir(), "db.sqlite")}}
|
||||
defer updater.Store.Close()
|
||||
|
||||
available, updateResp := updater.checkForUpdate(ctx)
|
||||
if !available {
|
||||
t.Fatalf("expected production update check to offer an update for spoofed version %s", spoofedVersion)
|
||||
}
|
||||
if updateResp.UpdateURL == "" {
|
||||
t.Fatal("production update response did not include a download URL")
|
||||
}
|
||||
t.Logf("production update version=%q url=%q", updateResp.UpdateVersion, updateResp.UpdateURL)
|
||||
|
||||
if err := updater.DownloadNewRelease(ctx, updateResp); err != nil {
|
||||
t.Fatalf("download production update: %v", err)
|
||||
}
|
||||
|
||||
staged := getStagedUpdate()
|
||||
if staged == "" {
|
||||
t.Fatal("production update was not staged")
|
||||
}
|
||||
t.Logf("staged production update at %s", staged)
|
||||
|
||||
assertPathInsideDir(t, UpdateStageDir, staged)
|
||||
if filepath.Base(staged) != expectedFilename {
|
||||
t.Fatalf("expected staged %s update filename to be %q, got %q", runtime.GOOS, expectedFilename, filepath.Base(staged))
|
||||
}
|
||||
expectedExt := filepath.Ext(expectedFilename)
|
||||
if filepath.Ext(staged) != expectedExt {
|
||||
t.Fatalf("expected staged %s update to be a %s artifact, got %s", runtime.GOOS, expectedExt, staged)
|
||||
}
|
||||
|
||||
info, err := os.Stat(staged)
|
||||
if err != nil {
|
||||
t.Fatalf("stat staged update: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatal("staged production update is empty")
|
||||
}
|
||||
|
||||
if !verifyCalled {
|
||||
t.Fatal("DownloadNewRelease did not call VerifyDownload")
|
||||
}
|
||||
t.Logf("production updater download path verified staged %s update", runtime.GOOS)
|
||||
}
|
||||
|
||||
func assertPathInsideDir(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
|
||||
dir, err := filepath.Abs(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, err = filepath.Abs(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(dir, name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
t.Fatalf("staged update escaped update stage dir: %s", name)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -19,6 +21,52 @@ import (
|
||||
"github.com/ollama/ollama/app/store"
|
||||
)
|
||||
|
||||
func TestUpdateStagePathRejectsUnsafeFilename(t *testing.T) {
|
||||
stageDir := t.TempDir()
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
filename string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"dot", "."},
|
||||
{"dotdot", ".."},
|
||||
{"posix_parent", "../OllamaSetup.exe"},
|
||||
{"windows_parent", `..\OllamaSetup.exe`},
|
||||
{"posix_absolute_tmp", "/tmp/OllamaSetup.exe"},
|
||||
{"darwin_absolute_app", "/Applications/Ollama.app"},
|
||||
{"darwin_bundle_path", "Ollama.app/Contents/MacOS/Ollama"},
|
||||
{"darwin_user_download", "~/Downloads/Ollama-darwin.zip"},
|
||||
{"windows_absolute", `C:\Users\Public\OllamaSetup.exe`},
|
||||
{"colon", "Ollama:Setup.exe"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if _, err := updateStagePath(stageDir, "etag", tt.filename); err == nil {
|
||||
t.Fatal("expected unsafe filename to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStagePathHashesETag(t *testing.T) {
|
||||
stageDir := t.TempDir()
|
||||
stageFilename, err := updateStagePath(stageDir, `../escaped`, "OllamaSetup.exe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rel, err := filepath.Rel(stageDir, stageFilename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
t.Fatalf("stage filename escaped stage dir: %s", stageFilename)
|
||||
}
|
||||
etagDir := filepath.Base(filepath.Dir(stageFilename))
|
||||
if etagDir == ".." || etagDir == "escaped" || strings.ContainsAny(etagDir, `/\`) {
|
||||
t.Fatalf("stage filename used raw etag path component: %s", stageFilename)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsNewReleaseAvailable(t *testing.T) {
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
var server *httptest.Server
|
||||
@@ -47,6 +95,223 @@ func TestIsNewReleaseAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadNewReleaseRejectsUnsafeHeaderFilename(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
oldInstaller := Installer
|
||||
oldVerifyDownload := VerifyDownload
|
||||
oldUpdateDownloaded := UpdateDownloaded
|
||||
defer func() {
|
||||
Installer = oldInstaller
|
||||
VerifyDownload = oldVerifyDownload
|
||||
UpdateDownloaded = oldUpdateDownloaded
|
||||
}()
|
||||
Installer = "OllamaSetup.exe"
|
||||
UpdateDownloaded = false
|
||||
VerifyDownload = func() error {
|
||||
t.Fatal("verification should not run for rejected downloads")
|
||||
return nil
|
||||
}
|
||||
|
||||
var getAttempted atomic.Bool
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodHead {
|
||||
w.Header().Set("ETag", `"safe"`)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="../OllamaSetup.exe"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
getAttempted.Store(true)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
updater := &Updater{}
|
||||
err := updater.DownloadNewRelease(t.Context(), UpdateResponse{UpdateURL: server.URL + "/download"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsafe update filename") {
|
||||
t.Fatalf("expected unsafe filename error, got %v", err)
|
||||
}
|
||||
if getAttempted.Load() {
|
||||
t.Fatal("download should not continue after unsafe filename")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(UpdateStageDir), "OllamaSetup.exe")); err == nil {
|
||||
t.Fatal("download escaped update stage dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloadNewReleaseDoesNotUseRawETagAsPathComponent(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
oldInstaller := Installer
|
||||
oldVerifyDownload := VerifyDownload
|
||||
oldUpdateDownloaded := UpdateDownloaded
|
||||
defer func() {
|
||||
Installer = oldInstaller
|
||||
VerifyDownload = oldVerifyDownload
|
||||
UpdateDownloaded = oldUpdateDownloaded
|
||||
}()
|
||||
Installer = "OllamaSetup.exe"
|
||||
UpdateDownloaded = false
|
||||
VerifyDownload = func() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := []byte("payload")
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("ETag", `"../escaped"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if r.Method == http.MethodGet {
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
updater := &Updater{}
|
||||
if err := updater.DownloadNewRelease(t.Context(), UpdateResponse{UpdateURL: server.URL + "/download"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(filepath.Dir(UpdateStageDir), "escaped", Installer)); err == nil {
|
||||
t.Fatal("download escaped update stage dir via etag")
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(UpdateStageDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("expected one staged update dir, got %d", len(entries))
|
||||
}
|
||||
stageFilename := filepath.Join(UpdateStageDir, entries[0].Name(), Installer)
|
||||
got, err := os.ReadFile(stageFilename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("unexpected staged payload %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgroundCheckerSkipsAlreadyStagedETagDownload(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
oldInstaller := Installer
|
||||
oldVerifyDownload := VerifyDownload
|
||||
oldUpdateDownloaded := UpdateDownloaded
|
||||
oldUpdateCheckInitialDelay := UpdateCheckInitialDelay
|
||||
oldUpdateCheckInterval := UpdateCheckInterval
|
||||
oldUpdateCheckURLBase := UpdateCheckURLBase
|
||||
defer func() {
|
||||
Installer = oldInstaller
|
||||
VerifyDownload = oldVerifyDownload
|
||||
UpdateDownloaded = oldUpdateDownloaded
|
||||
UpdateCheckInitialDelay = oldUpdateCheckInitialDelay
|
||||
UpdateCheckInterval = oldUpdateCheckInterval
|
||||
UpdateCheckURLBase = oldUpdateCheckURLBase
|
||||
}()
|
||||
Installer = "OllamaSetup.exe"
|
||||
UpdateDownloaded = false
|
||||
UpdateCheckInitialDelay = time.Millisecond
|
||||
UpdateCheckInterval = 5 * time.Millisecond
|
||||
|
||||
var verifyCount atomic.Int32
|
||||
VerifyDownload = func() error {
|
||||
verifyCount.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
headETag := `"old-update"`
|
||||
getETag := `"download-response-etag"`
|
||||
payload := []byte("payload")
|
||||
var headCount atomic.Int32
|
||||
var getCount atomic.Int32
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/update.json":
|
||||
w.Write([]byte(
|
||||
fmt.Sprintf(`{"version": "9.9.9", "url": "%s"}`,
|
||||
server.URL+"/9.9.9/"+Installer)))
|
||||
case "/9.9.9/" + Installer:
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="OllamaSetup.exe"`)
|
||||
switch r.Method {
|
||||
case http.MethodHead:
|
||||
etag := headETag
|
||||
if getCount.Load() > 0 {
|
||||
etag = getETag
|
||||
}
|
||||
w.Header().Set("ETag", etag)
|
||||
headCount.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
case http.MethodGet:
|
||||
w.Header().Set("ETag", getETag)
|
||||
getCount.Add(1)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(payload)
|
||||
default:
|
||||
t.Errorf("unexpected request method %s", r.Method)
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
default:
|
||||
t.Errorf("unexpected request path %s", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
UpdateCheckURLBase = server.URL + "/update.json"
|
||||
|
||||
updater := &Updater{Store: &store.Store{DBPath: filepath.Join(t.TempDir(), "test.db")}}
|
||||
defer updater.Store.Close()
|
||||
settings, err := updater.Store.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
settings.AutoUpdateEnabled = true
|
||||
if err := updater.Store.SetSettings(settings); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
|
||||
callbacks := make(chan string, 4)
|
||||
updater.StartBackgroundUpdaterChecker(ctx, func(ver string) error {
|
||||
callbacks <- ver
|
||||
return nil
|
||||
})
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
case <-callbacks:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for repeated update checks")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
|
||||
stageFilename, err := updateStagePath(UpdateStageDir, getETag, Installer)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(stageFilename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("unexpected staged payload %q", got)
|
||||
}
|
||||
|
||||
if headCount.Load() < 2 {
|
||||
t.Fatalf("HEAD count = %d, want at least 2", headCount.Load())
|
||||
}
|
||||
if getCount.Load() != 1 {
|
||||
t.Fatalf("GET count = %d, want 1", getCount.Load())
|
||||
}
|
||||
if verifyCount.Load() != 1 {
|
||||
t.Fatalf("verification count = %d, want 1", verifyCount.Load())
|
||||
}
|
||||
if !UpdateDownloaded {
|
||||
t.Fatal("UpdateDownloaded should stay true for already staged update")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackgoundChecker(t *testing.T) {
|
||||
UpdateStageDir = t.TempDir()
|
||||
haveUpdate := false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package updater
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -18,6 +19,30 @@ import (
|
||||
|
||||
var runningInstaller string
|
||||
|
||||
var (
|
||||
crypt32 = windows.NewLazySystemDLL("crypt32.dll")
|
||||
procCryptMsgGetParam = crypt32.NewProc("CryptMsgGetParam")
|
||||
procCryptMsgClose = crypt32.NewProc("CryptMsgClose")
|
||||
)
|
||||
|
||||
const cmsgSignerInfoParam = 6
|
||||
|
||||
type cmsgSignerInfo struct {
|
||||
Version uint32
|
||||
Issuer windows.CertNameBlob
|
||||
SerialNumber windows.CryptIntegerBlob
|
||||
HashAlgorithm windows.CryptAlgorithmIdentifier
|
||||
HashEncryptionAlgorithm windows.CryptAlgorithmIdentifier
|
||||
EncryptedHash windows.CryptDataBlob
|
||||
AuthAttrs cryptAttributes
|
||||
UnauthAttrs cryptAttributes
|
||||
}
|
||||
|
||||
type cryptAttributes struct {
|
||||
Count uint32
|
||||
Attributes unsafe.Pointer
|
||||
}
|
||||
|
||||
type OSVERSIONINFOEXW struct {
|
||||
dwOSVersionInfoSize uint32
|
||||
dwMajorVersion uint32
|
||||
@@ -99,6 +124,12 @@ func DoUpgrade(interactive bool) error {
|
||||
return fmt.Errorf("failed to lookup downloads")
|
||||
}
|
||||
|
||||
if err := VerifyDownload(); err != nil {
|
||||
_ = os.Remove(bundle)
|
||||
slog.Warn("verification failure", "bundle", bundle, "error", err)
|
||||
return fmt.Errorf("staged update verification failed: %w", err)
|
||||
}
|
||||
|
||||
// We move the installer to ensure we don't race with multiple apps starting in quick succession
|
||||
if err := os.Rename(bundle, runningInstaller); err != nil {
|
||||
return fmt.Errorf("unable to rename %s -> %s : %w", bundle, runningInstaller, err)
|
||||
@@ -184,6 +215,150 @@ func DoPostUpgradeCleanup() error {
|
||||
}
|
||||
|
||||
func verifyDownload() error {
|
||||
bundle := getStagedUpdate()
|
||||
if bundle == "" {
|
||||
return fmt.Errorf("failed to lookup downloads")
|
||||
}
|
||||
slog.Debug("verifying update", "bundle", bundle)
|
||||
|
||||
if err := verifyWindowsInstallerSignature(bundle); err != nil {
|
||||
return fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyWindowsInstallerSignature(filename string) error {
|
||||
filename16, err := windows.UTF16PtrFromString(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data := &windows.WinTrustData{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
|
||||
UIChoice: windows.WTD_UI_NONE,
|
||||
RevocationChecks: windows.WTD_REVOKE_WHOLECHAIN,
|
||||
UnionChoice: windows.WTD_CHOICE_FILE,
|
||||
StateAction: windows.WTD_STATEACTION_VERIFY,
|
||||
UIContext: windows.WTD_UICONTEXT_INSTALL,
|
||||
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(&windows.WinTrustFileInfo{
|
||||
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
|
||||
FilePath: filename16,
|
||||
}),
|
||||
}
|
||||
|
||||
verifyErr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
|
||||
data.StateAction = windows.WTD_STATEACTION_CLOSE
|
||||
closeErr := windows.WinVerifyTrustEx(windows.InvalidHWND, &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2, data)
|
||||
if verifyErr != nil {
|
||||
return verifyErr
|
||||
}
|
||||
if closeErr != nil {
|
||||
return fmt.Errorf("close WinVerifyTrust state: %w", closeErr)
|
||||
}
|
||||
|
||||
subject, err := windowsInstallerSignerSubject(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
slog.Debug("verified update signature", "subject", subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
func windowsInstallerSignerSubject(filename string) (string, error) {
|
||||
filename16, err := windows.UTF16PtrFromString(filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var certStore windows.Handle
|
||||
var msg windows.Handle
|
||||
if err := windows.CryptQueryObject(
|
||||
windows.CERT_QUERY_OBJECT_FILE,
|
||||
unsafe.Pointer(filename16),
|
||||
windows.CERT_QUERY_CONTENT_FLAG_PKCS7_SIGNED_EMBED,
|
||||
windows.CERT_QUERY_FORMAT_FLAG_BINARY,
|
||||
0,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
&certStore,
|
||||
&msg,
|
||||
nil,
|
||||
); err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer windows.CertCloseStore(certStore, 0) //nolint:errcheck
|
||||
defer cryptMsgClose(msg) //nolint:errcheck
|
||||
|
||||
var signerInfoSize uint32
|
||||
if err := cryptMsgGetParam(msg, cmsgSignerInfoParam, 0, nil, &signerInfoSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if signerInfoSize == 0 {
|
||||
return "", fmt.Errorf("missing signer info")
|
||||
}
|
||||
|
||||
signerInfoBuf := make([]byte, signerInfoSize)
|
||||
if err := cryptMsgGetParam(msg, cmsgSignerInfoParam, 0, unsafe.Pointer(&signerInfoBuf[0]), &signerInfoSize); err != nil {
|
||||
return "", err
|
||||
}
|
||||
signerInfo := (*cmsgSignerInfo)(unsafe.Pointer(&signerInfoBuf[0]))
|
||||
certInfo := windows.CertInfo{
|
||||
Issuer: signerInfo.Issuer,
|
||||
SerialNumber: signerInfo.SerialNumber,
|
||||
}
|
||||
|
||||
cert, err := windows.CertFindCertificateInStore(
|
||||
certStore,
|
||||
windows.X509_ASN_ENCODING|windows.PKCS_7_ASN_ENCODING,
|
||||
0,
|
||||
windows.CERT_FIND_SUBJECT_CERT,
|
||||
unsafe.Pointer(&certInfo),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer windows.CertFreeCertificateContext(cert) //nolint:errcheck
|
||||
|
||||
parsed, err := x509.ParseCertificate(unsafe.Slice(cert.EncodedCert, cert.Length))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, org := range parsed.Subject.Organization {
|
||||
if org == "Ollama Inc." {
|
||||
return parsed.Subject.String(), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unexpected signer: %s", parsed.Subject.String())
|
||||
}
|
||||
|
||||
func cryptMsgGetParam(msg windows.Handle, paramType, index uint32, data unsafe.Pointer, size *uint32) error {
|
||||
r1, _, e1 := procCryptMsgGetParam.Call(
|
||||
uintptr(msg),
|
||||
uintptr(paramType),
|
||||
uintptr(index),
|
||||
uintptr(data),
|
||||
uintptr(unsafe.Pointer(size)),
|
||||
)
|
||||
if r1 == 0 {
|
||||
if e1 != syscall.Errno(0) {
|
||||
return e1
|
||||
}
|
||||
return syscall.EINVAL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cryptMsgClose(msg windows.Handle) error {
|
||||
r1, _, e1 := procCryptMsgClose.Call(uintptr(msg))
|
||||
if r1 == 0 {
|
||||
if e1 != syscall.Errno(0) {
|
||||
return e1
|
||||
}
|
||||
return syscall.EINVAL
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,85 @@
|
||||
//go:build windows || darwin
|
||||
//go:build windows
|
||||
|
||||
package updater
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifyDownloadRejectsUnsignedWindowsInstaller(t *testing.T) {
|
||||
oldUpdateStageDir := UpdateStageDir
|
||||
defer func() {
|
||||
UpdateStageDir = oldUpdateStageDir
|
||||
}()
|
||||
|
||||
t.Setenv("LOCALAPPDATA", t.TempDir())
|
||||
UpdateStageDir = t.TempDir()
|
||||
bundle := filepath.Join(UpdateStageDir, "etag", "OllamaSetup.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(bundle, []byte("not a signed installer"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := verifyDownload()
|
||||
if err == nil || !strings.Contains(err.Error(), "signature verification failed") {
|
||||
t.Fatalf("expected signature verification failure, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoUpgradeAtStartupRejectsUnsignedWindowsInstaller(t *testing.T) {
|
||||
oldUpdateStageDir := UpdateStageDir
|
||||
oldRunningInstaller := runningInstaller
|
||||
oldUpgradeLogFile := UpgradeLogFile
|
||||
oldUpgradeMarkerFile := UpgradeMarkerFile
|
||||
oldVerifyDownload := VerifyDownload
|
||||
defer func() {
|
||||
UpdateStageDir = oldUpdateStageDir
|
||||
runningInstaller = oldRunningInstaller
|
||||
UpgradeLogFile = oldUpgradeLogFile
|
||||
UpgradeMarkerFile = oldUpgradeMarkerFile
|
||||
VerifyDownload = oldVerifyDownload
|
||||
}()
|
||||
|
||||
t.Setenv("LOCALAPPDATA", t.TempDir())
|
||||
UpdateStageDir = t.TempDir()
|
||||
runDir := t.TempDir()
|
||||
runningInstaller = filepath.Join(runDir, "OllamaSetup.exe")
|
||||
UpgradeLogFile = filepath.Join(runDir, "upgrade.log")
|
||||
UpgradeMarkerFile = filepath.Join(runDir, "upgraded")
|
||||
VerifyDownload = verifyDownload
|
||||
|
||||
bundle := filepath.Join(UpdateStageDir, "etag", "OllamaSetup.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(bundle), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(bundle, []byte("not a signed installer"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := DoUpgradeAtStartup()
|
||||
if err == nil || !strings.Contains(err.Error(), "signature verification failed") {
|
||||
t.Fatalf("expected signature verification failure, got %v", err)
|
||||
}
|
||||
if _, err := os.Stat(runningInstaller); !os.IsNotExist(err) {
|
||||
t.Fatalf("unsigned installer was moved before verification failed: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(bundle); !os.IsNotExist(err) {
|
||||
t.Fatalf("unsigned staged installer was not removed after verification failure: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsInstallerRunning(t *testing.T) {
|
||||
oldInstaller := Installer
|
||||
defer func() {
|
||||
Installer = oldInstaller
|
||||
}()
|
||||
|
||||
slog.SetLogLoggerLevel(slog.LevelDebug)
|
||||
Installer = "go.exe"
|
||||
if !isInstallerRunning() {
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
# Local Ollama superbuild targets.
|
||||
#
|
||||
# This file keeps the repository-root CMake project focused on orchestration:
|
||||
# it builds a runnable local Ollama payload by delegating llama.cpp work to the
|
||||
# llama/server CMake project and building the Go binary into a matching layout.
|
||||
|
||||
include(ExternalProject)
|
||||
|
||||
set(OLLAMA_LLAMA_BACKENDS "" CACHE STRING
|
||||
"Semicolon-separated llama-server GPU backends to build: cuda_v12;cuda_v13;rocm_v7_1;rocm_v7_2;vulkan;cuda_jetpack5;cuda_jetpack6")
|
||||
set(_ollama_mlx_backends_doc "Semicolon-separated MLX backends to build: cuda_v13;metal_v3;metal_v4")
|
||||
set(OLLAMA_VERSION "0.0.0" CACHE STRING "Ollama version embedded in the local Go binary")
|
||||
set(OLLAMA_PAYLOAD_INSTALL_PREFIX "${CMAKE_BINARY_DIR}" CACHE PATH
|
||||
"Build-time staging prefix for nested Ollama native payloads")
|
||||
|
||||
string(REGEX REPLACE "^v" "" OLLAMA_VERSION "${OLLAMA_VERSION}")
|
||||
|
||||
set(OLLAMA_NATIVE_CONFIG_ARG)
|
||||
if(CMAKE_CONFIGURATION_TYPES)
|
||||
set(OLLAMA_NATIVE_CONFIG_ARG --config Release)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_NATIVE_EXTERNAL_OPTIONS)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.28)
|
||||
list(APPEND OLLAMA_NATIVE_EXTERNAL_OPTIONS BUILD_JOB_SERVER_AWARE TRUE)
|
||||
endif()
|
||||
|
||||
function(ollama_check_metal_toolchain output_version)
|
||||
find_program(_ollama_xcrun xcrun)
|
||||
if(NOT _ollama_xcrun)
|
||||
message(FATAL_ERROR
|
||||
"MLX Metal requires Xcode command line tools. Install Xcode, run "
|
||||
"`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, "
|
||||
"then install the Metal toolchain with "
|
||||
"`xcodebuild -downloadComponent MetalToolchain`.")
|
||||
endif()
|
||||
|
||||
execute_process(
|
||||
COMMAND zsh "-c"
|
||||
"echo \"__METAL_VERSION__\" | \"${_ollama_xcrun}\" -sdk macosx metal -E -x metal -P - 2>/dev/null | tail -1 | tr -d '\n'"
|
||||
OUTPUT_VARIABLE _metal_version
|
||||
RESULT_VARIABLE _metal_result)
|
||||
if(NOT _metal_result EQUAL 0 OR NOT _metal_version MATCHES "^[0-9]+$")
|
||||
message(FATAL_ERROR
|
||||
"MLX Metal requires Xcode's Metal toolchain. Install Xcode, run "
|
||||
"`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, "
|
||||
"then install the Metal toolchain with "
|
||||
"`xcodebuild -downloadComponent MetalToolchain`.")
|
||||
endif()
|
||||
|
||||
set(${output_version} "${_metal_version}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_macos_major_version output)
|
||||
execute_process(
|
||||
COMMAND sw_vers -productVersion
|
||||
OUTPUT_VARIABLE _macos_version
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
RESULT_VARIABLE _macos_result
|
||||
ERROR_QUIET)
|
||||
if(_macos_result EQUAL 0)
|
||||
string(REGEX MATCH "^[0-9]+" _macos_major "${_macos_version}")
|
||||
endif()
|
||||
set(${output} "${_macos_major}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_macos_sdk_major_version output)
|
||||
execute_process(
|
||||
COMMAND xcrun --sdk macosx --show-sdk-version
|
||||
OUTPUT_VARIABLE _sdk_version
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
RESULT_VARIABLE _sdk_result
|
||||
ERROR_QUIET)
|
||||
if(_sdk_result EQUAL 0)
|
||||
string(REGEX MATCH "^[0-9]+" _sdk_major "${_sdk_version}")
|
||||
endif()
|
||||
set(${output} "${_sdk_major}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_default_mlx_backends output)
|
||||
set(_backends "")
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
ollama_check_metal_toolchain(_metal_version)
|
||||
ollama_macos_major_version(_macos_major)
|
||||
ollama_macos_sdk_major_version(_sdk_major)
|
||||
if(_macos_major AND _sdk_major AND _macos_major GREATER_EQUAL 26 AND _sdk_major GREATER_EQUAL 26)
|
||||
set(_backends "metal_v4")
|
||||
else()
|
||||
set(_backends "metal_v3")
|
||||
endif()
|
||||
message(STATUS "Defaulting OLLAMA_MLX_BACKENDS=${_backends} for macOS arm64")
|
||||
endif()
|
||||
set(${output} "${_backends}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
if(NOT DEFINED OLLAMA_MLX_BACKENDS)
|
||||
ollama_default_mlx_backends(_ollama_default_mlx_backends)
|
||||
set(OLLAMA_MLX_BACKENDS "${_ollama_default_mlx_backends}" CACHE STRING "${_ollama_mlx_backends_doc}")
|
||||
else()
|
||||
set(OLLAMA_MLX_BACKENDS "${OLLAMA_MLX_BACKENDS}" CACHE STRING "${_ollama_mlx_backends_doc}")
|
||||
endif()
|
||||
|
||||
if(NOT OLLAMA_HAVE_LLAMA_SERVER)
|
||||
if(OLLAMA_LLAMA_BACKENDS)
|
||||
message(FATAL_ERROR "llama/server is required when OLLAMA_LLAMA_BACKENDS is set")
|
||||
endif()
|
||||
if(NOT OLLAMA_MLX_BACKENDS)
|
||||
message(FATAL_ERROR "llama/server is required for local Ollama builds")
|
||||
endif()
|
||||
else()
|
||||
file(READ "${CMAKE_SOURCE_DIR}/LLAMA_CPP_VERSION" OLLAMA_LLAMA_CPP_GIT_TAG)
|
||||
string(STRIP "${OLLAMA_LLAMA_CPP_GIT_TAG}" OLLAMA_LLAMA_CPP_GIT_TAG)
|
||||
include(${CMAKE_SOURCE_DIR}/llama/compat/compat.cmake)
|
||||
if(DEFINED FETCHCONTENT_SOURCE_DIR_LLAMA_CPP AND NOT "${FETCHCONTENT_SOURCE_DIR_LLAMA_CPP}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_LLAMA_CPP_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_LLAMA_CPP}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using llama.cpp source override: ${OLLAMA_LLAMA_CPP_SOURCE_DIR}")
|
||||
add_custom_target(ollama-llama-cpp-source)
|
||||
elseif(DEFINED ENV{OLLAMA_LLAMA_CPP_SOURCE})
|
||||
get_filename_component(OLLAMA_LLAMA_CPP_SOURCE_DIR
|
||||
"$ENV{OLLAMA_LLAMA_CPP_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using local llama.cpp source: ${OLLAMA_LLAMA_CPP_SOURCE_DIR}")
|
||||
add_custom_target(ollama-llama-cpp-source)
|
||||
else()
|
||||
set(OLLAMA_LLAMA_CPP_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/llama_cpp-src")
|
||||
ExternalProject_Add(ollama-llama-cpp-source
|
||||
GIT_REPOSITORY "https://github.com/ggml-org/llama.cpp.git"
|
||||
GIT_TAG ${OLLAMA_LLAMA_CPP_GIT_TAG}
|
||||
GIT_SHALLOW TRUE
|
||||
SOURCE_DIR ${OLLAMA_LLAMA_CPP_SOURCE_DIR}
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
PATCH_COMMAND ${OLLAMA_LLAMA_CPP_COMPAT_PATCH_COMMAND}
|
||||
USES_TERMINAL_DOWNLOAD TRUE
|
||||
USES_TERMINAL_PATCH TRUE)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(_mlx_source_targets)
|
||||
if(OLLAMA_MLX_BACKENDS)
|
||||
file(READ "${CMAKE_SOURCE_DIR}/MLX_VERSION" OLLAMA_MLX_GIT_TAG)
|
||||
string(STRIP "${OLLAMA_MLX_GIT_TAG}" OLLAMA_MLX_GIT_TAG)
|
||||
file(READ "${CMAKE_SOURCE_DIR}/MLX_C_VERSION" OLLAMA_MLX_C_GIT_TAG)
|
||||
string(STRIP "${OLLAMA_MLX_C_GIT_TAG}" OLLAMA_MLX_C_GIT_TAG)
|
||||
|
||||
if(DEFINED FETCHCONTENT_SOURCE_DIR_MLX AND NOT "${FETCHCONTENT_SOURCE_DIR_MLX}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_MLX_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_MLX}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using MLX source override: ${OLLAMA_MLX_SOURCE_DIR}")
|
||||
elseif(DEFINED ENV{OLLAMA_MLX_SOURCE})
|
||||
get_filename_component(OLLAMA_MLX_SOURCE_DIR
|
||||
"$ENV{OLLAMA_MLX_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using local MLX source: ${OLLAMA_MLX_SOURCE_DIR}")
|
||||
else()
|
||||
set(OLLAMA_MLX_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/mlx-src")
|
||||
ExternalProject_Add(ollama-mlx-source
|
||||
GIT_REPOSITORY "https://github.com/ml-explore/mlx.git"
|
||||
GIT_TAG ${OLLAMA_MLX_GIT_TAG}
|
||||
# MLX uses commit hashes while we track closely; switch to shallow when MLX pins move to tags.
|
||||
GIT_SHALLOW FALSE
|
||||
SOURCE_DIR ${OLLAMA_MLX_SOURCE_DIR}
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
USES_TERMINAL_DOWNLOAD TRUE)
|
||||
list(APPEND _mlx_source_targets ollama-mlx-source)
|
||||
endif()
|
||||
|
||||
if(DEFINED "FETCHCONTENT_SOURCE_DIR_MLX-C" AND NOT "${FETCHCONTENT_SOURCE_DIR_MLX-C}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_MLX_C_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_MLX-C}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using MLX-C source override: ${OLLAMA_MLX_C_SOURCE_DIR}")
|
||||
elseif(DEFINED ENV{OLLAMA_MLX_C_SOURCE})
|
||||
get_filename_component(OLLAMA_MLX_C_SOURCE_DIR
|
||||
"$ENV{OLLAMA_MLX_C_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using local MLX-C source: ${OLLAMA_MLX_C_SOURCE_DIR}")
|
||||
else()
|
||||
set(OLLAMA_MLX_C_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/mlx-c-src")
|
||||
ExternalProject_Add(ollama-mlx-c-source
|
||||
GIT_REPOSITORY "https://github.com/ml-explore/mlx-c.git"
|
||||
GIT_TAG ${OLLAMA_MLX_C_GIT_TAG}
|
||||
# MLX-C uses commit hashes while we track closely; switch to shallow when MLX-C pins move to tags.
|
||||
GIT_SHALLOW FALSE
|
||||
SOURCE_DIR ${OLLAMA_MLX_C_SOURCE_DIR}
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
USES_TERMINAL_DOWNLOAD TRUE)
|
||||
list(APPEND _mlx_source_targets ollama-mlx-c-source)
|
||||
endif()
|
||||
add_custom_target(ollama-mlx-sources DEPENDS ${_mlx_source_targets})
|
||||
endif()
|
||||
|
||||
set(OLLAMA_NATIVE_BUILD_TOOL_COMMAND
|
||||
${CMAKE_COMMAND} --build <BINARY_DIR>)
|
||||
set(OLLAMA_NATIVE_BUILD_TARGET_ARG --target)
|
||||
if(CMAKE_GENERATOR MATCHES "Makefiles")
|
||||
set(OLLAMA_NATIVE_BUILD_TOOL_COMMAND
|
||||
"$(MAKE)" -C <BINARY_DIR>)
|
||||
set(OLLAMA_NATIVE_BUILD_TARGET_ARG)
|
||||
endif()
|
||||
|
||||
function(ollama_escape_cmake_list input output)
|
||||
string(REPLACE ";" "|" _escaped "${input}")
|
||||
set(${output} "${_escaped}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_collect_cache_args_with_prefix prefix output)
|
||||
get_cmake_property(_cache_variables CACHE_VARIABLES)
|
||||
list(SORT _cache_variables)
|
||||
|
||||
set(_args)
|
||||
foreach(_var IN LISTS _cache_variables)
|
||||
if(_var MATCHES "^${prefix}")
|
||||
ollama_escape_cmake_list("${${_var}}" _value)
|
||||
list(APPEND _args "-D${_var}=${_value}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(${output} "${_args}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_append_cache_arg_if_set output name)
|
||||
if(DEFINED ${name} AND NOT "${${name}}" STREQUAL "")
|
||||
ollama_escape_cmake_list("${${name}}" _value)
|
||||
set(${output} ${${output}} "-D${name}=${_value}" PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(ollama_cache_arg_is_set name output)
|
||||
if(DEFINED ${name} AND NOT "${${name}}" STREQUAL "")
|
||||
set(${output} TRUE PARENT_SCOPE)
|
||||
else()
|
||||
set(${output} FALSE PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(ollama_llama_cuda_preset backend output)
|
||||
ollama_cache_arg_is_set(CMAKE_CUDA_ARCHITECTURES _has_cuda_arch)
|
||||
if(_has_cuda_arch)
|
||||
set(_preset "llama_${backend}_user_arch")
|
||||
elseif(WIN32)
|
||||
set(_preset "llama_${backend}_windows")
|
||||
else()
|
||||
set(_preset "llama_${backend}_linux")
|
||||
endif()
|
||||
set(${output} "${_preset}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_mlx_cuda_preset output)
|
||||
ollama_cache_arg_is_set(MLX_CUDA_ARCHITECTURES _has_mlx_arch)
|
||||
ollama_cache_arg_is_set(CMAKE_CUDA_ARCHITECTURES _has_cuda_arch)
|
||||
if(_has_mlx_arch OR _has_cuda_arch)
|
||||
set(_preset "mlx_cuda_v13_user_arch")
|
||||
elseif(WIN32)
|
||||
set(_preset "mlx_cuda_v13_windows")
|
||||
else()
|
||||
set(_preset "mlx_cuda_v13_linux")
|
||||
endif()
|
||||
set(${output} "${_preset}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_rocm_preset backend output)
|
||||
ollama_cache_arg_is_set(AMDGPU_TARGETS _has_amdgpu_targets)
|
||||
ollama_cache_arg_is_set(CMAKE_HIP_ARCHITECTURES _has_hip_arch)
|
||||
if(_has_amdgpu_targets OR _has_hip_arch)
|
||||
if(backend STREQUAL "rocm_v7_1" AND NOT WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_1 is only supported for Windows ROCm builds")
|
||||
elseif(backend STREQUAL "rocm_v7_2" AND WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_2 is only supported for Linux ROCm builds")
|
||||
endif()
|
||||
elseif(backend STREQUAL "rocm_v7_1")
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_1 is only supported for Windows ROCm builds")
|
||||
endif()
|
||||
set(_preset "${backend}_windows")
|
||||
elseif(backend STREQUAL "rocm_v7_2")
|
||||
if(WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_2 is only supported for Linux ROCm builds")
|
||||
endif()
|
||||
set(_preset "${backend}_linux")
|
||||
else()
|
||||
message(FATAL_ERROR "Unknown ROCm backend '${backend}'")
|
||||
endif()
|
||||
if(_has_amdgpu_targets OR _has_hip_arch)
|
||||
set(_preset "${backend}_user_arch")
|
||||
endif()
|
||||
set(${output} "${_preset}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_add_llama_server_build name)
|
||||
cmake_parse_arguments(ARG "" "PRESET;RUNNER_DIR" "TARGETS;CMAKE_ARGS" ${ARGN})
|
||||
if(NOT ARG_TARGETS)
|
||||
message(FATAL_ERROR "ollama_add_llama_server_build(${name}) requires TARGETS")
|
||||
endif()
|
||||
|
||||
if(WIN32 AND name STREQUAL "vulkan")
|
||||
# The Vulkan shader generator nests deeply enough to hit Windows MAX_PATH.
|
||||
set(_build_dir ${CMAKE_BINARY_DIR}/ls-vk)
|
||||
else()
|
||||
set(_build_dir ${CMAKE_BINARY_DIR}/llama-server-${name})
|
||||
endif()
|
||||
ollama_collect_cache_args_with_prefix("GGML_" _ggml_cache_args)
|
||||
ollama_collect_cache_args_with_prefix("LLAMA_" _llama_cache_args)
|
||||
set(_cmake_args
|
||||
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
|
||||
-DCMAKE_INSTALL_PREFIX=${OLLAMA_PAYLOAD_INSTALL_PREFIX}
|
||||
-DOLLAMA_LIB_DIR:STRING=${OLLAMA_LIB_DIR}
|
||||
-DOLLAMA_RUNNER_DIR=${ARG_RUNNER_DIR}
|
||||
-DFETCHCONTENT_SOURCE_DIR_LLAMA_CPP=${OLLAMA_LLAMA_CPP_SOURCE_DIR}
|
||||
-DOLLAMA_LLAMA_CPP_SKIP_COMPAT_PATCH=ON
|
||||
-DGGML_NATIVE=OFF
|
||||
-DGGML_OPENMP=OFF
|
||||
${ARG_CMAKE_ARGS}
|
||||
${_ggml_cache_args}
|
||||
${_llama_cache_args}
|
||||
)
|
||||
|
||||
if(APPLE)
|
||||
if(CMAKE_OSX_ARCHITECTURES)
|
||||
list(APPEND _cmake_args
|
||||
-DCMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES})
|
||||
endif()
|
||||
if(CMAKE_OSX_DEPLOYMENT_TARGET)
|
||||
list(APPEND _cmake_args
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET})
|
||||
endif()
|
||||
endif()
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/llama/server
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
if(ARG_PRESET)
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/llama/server
|
||||
--preset ${ARG_PRESET}
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
endif()
|
||||
ExternalProject_Add(ollama-llama-server-${name}
|
||||
SOURCE_DIR ${CMAKE_SOURCE_DIR}/llama/server
|
||||
BINARY_DIR ${_build_dir}
|
||||
CONFIGURE_COMMAND ${_configure_command}
|
||||
BUILD_COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
${OLLAMA_NATIVE_BUILD_TARGET_ARG} ${ARG_TARGETS}
|
||||
INSTALL_COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
--component llama-server
|
||||
DEPENDS ollama-llama-cpp-source
|
||||
LIST_SEPARATOR |
|
||||
# ExternalProject cannot reliably infer when nested FetchContent
|
||||
# sources, compat patches, or forwarded GGML/LLAMA cache settings need
|
||||
# a rebuild. Always entering the sub-build keeps direct `cmake --build`
|
||||
# iteration correct; the nested generator still performs incremental
|
||||
# compilation.
|
||||
BUILD_ALWAYS TRUE
|
||||
${OLLAMA_NATIVE_EXTERNAL_OPTIONS}
|
||||
USES_TERMINAL_CONFIGURE TRUE
|
||||
USES_TERMINAL_BUILD TRUE
|
||||
USES_TERMINAL_INSTALL TRUE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_add_mlx_build name)
|
||||
cmake_parse_arguments(ARG "" "PRESET;RUNNER_DIR" "CMAKE_ARGS" ${ARGN})
|
||||
if(NOT ARG_RUNNER_DIR)
|
||||
message(FATAL_ERROR "ollama_add_mlx_build(${name}) requires RUNNER_DIR")
|
||||
endif()
|
||||
|
||||
set(_build_dir ${CMAKE_BINARY_DIR}/${ARG_RUNNER_DIR})
|
||||
ollama_collect_cache_args_with_prefix("MLX_" _mlx_cache_args)
|
||||
set(_cmake_args
|
||||
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
|
||||
-DCMAKE_INSTALL_PREFIX=${OLLAMA_PAYLOAD_INSTALL_PREFIX}
|
||||
-DOLLAMA_LIB_DIR:STRING=${OLLAMA_LIB_DIR}
|
||||
-DOLLAMA_RUNNER_DIR=${ARG_RUNNER_DIR}
|
||||
-DOLLAMA_SOURCE_DIR=${CMAKE_SOURCE_DIR}
|
||||
-DFETCHCONTENT_SOURCE_DIR_MLX=${OLLAMA_MLX_SOURCE_DIR}
|
||||
-DFETCHCONTENT_SOURCE_DIR_MLX-C=${OLLAMA_MLX_C_SOURCE_DIR}
|
||||
-DOLLAMA_MLX_GENERATE_WRAPPERS=OFF
|
||||
${ARG_CMAKE_ARGS}
|
||||
${_mlx_cache_args}
|
||||
)
|
||||
foreach(_arg IN ITEMS
|
||||
BLAS_INCLUDE_DIRS
|
||||
LAPACK_INCLUDE_DIRS
|
||||
CUDAToolkit_ROOT
|
||||
CUDNN_ROOT_DIR
|
||||
CUDNN_INCLUDE_PATH
|
||||
CUDNN_LIBRARY_PATH
|
||||
CMAKE_CUDA_COMPILER
|
||||
CMAKE_CUDA_HOST_COMPILER
|
||||
CMAKE_INCLUDE_PATH
|
||||
CMAKE_LIBRARY_PATH
|
||||
CMAKE_PREFIX_PATH)
|
||||
ollama_append_cache_arg_if_set(_cmake_args ${_arg})
|
||||
endforeach()
|
||||
|
||||
if(APPLE)
|
||||
if(CMAKE_OSX_ARCHITECTURES)
|
||||
list(APPEND _cmake_args
|
||||
-DCMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES})
|
||||
endif()
|
||||
endif()
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/cmake/mlx
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
if(ARG_PRESET)
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/cmake/mlx
|
||||
--preset ${ARG_PRESET}
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
endif()
|
||||
|
||||
ExternalProject_Add(ollama-mlx-${name}
|
||||
SOURCE_DIR ${CMAKE_SOURCE_DIR}/cmake/mlx
|
||||
BINARY_DIR ${_build_dir}
|
||||
CONFIGURE_COMMAND ${_configure_command}
|
||||
BUILD_COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlx
|
||||
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlxc
|
||||
INSTALL_COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
--component MLX
|
||||
COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
--component MLX_VENDOR
|
||||
DEPENDS ollama-mlx-sources
|
||||
LIST_SEPARATOR |
|
||||
BUILD_ALWAYS TRUE
|
||||
${OLLAMA_NATIVE_EXTERNAL_OPTIONS}
|
||||
USES_TERMINAL_CONFIGURE TRUE
|
||||
USES_TERMINAL_BUILD TRUE
|
||||
USES_TERMINAL_INSTALL TRUE)
|
||||
endfunction()
|
||||
|
||||
find_program(GO_EXECUTABLE go)
|
||||
|
||||
if(OLLAMA_MLX_BACKENDS)
|
||||
set(_mlx_c_headers_dir "${OLLAMA_MLX_C_SOURCE_DIR}/mlx/c")
|
||||
set(_mlx_c_headers_dest "${CMAKE_SOURCE_DIR}/x/mlxrunner/mlx/include/mlx/c")
|
||||
|
||||
if(GO_EXECUTABLE AND (NOT APPLE OR CMAKE_SYSTEM_PROCESSOR STREQUAL CMAKE_HOST_SYSTEM_PROCESSOR))
|
||||
add_custom_target(ollama-mlx-generate-wrappers
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DMLX_C_HEADERS_DIR=${_mlx_c_headers_dir}
|
||||
-DMLX_C_HEADERS_DEST=${_mlx_c_headers_dest}
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/vendor-mlx-c-headers.cmake"
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
CC= CGO_CFLAGS= CGO_CXXFLAGS=
|
||||
${GO_EXECUTABLE} generate ./x/...
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
DEPENDS ollama-mlx-sources
|
||||
COMMENT "Regenerating MLX Go wrappers"
|
||||
VERBATIM)
|
||||
else()
|
||||
add_custom_target(ollama-mlx-generate-wrappers
|
||||
COMMAND ${CMAKE_COMMAND} -E echo
|
||||
"Cannot regenerate MLX wrappers while Go is unavailable or while cross-compiling"
|
||||
COMMAND ${CMAKE_COMMAND} -E false
|
||||
DEPENDS ollama-mlx-sources
|
||||
VERBATIM)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(OLLAMA_HAVE_LLAMA_SERVER)
|
||||
if(NOT OLLAMA_GO_OUTPUT)
|
||||
if(WIN32)
|
||||
set(OLLAMA_GO_OUTPUT ${CMAKE_SOURCE_DIR}/ollama.exe)
|
||||
else()
|
||||
set(OLLAMA_GO_OUTPUT ${CMAKE_SOURCE_DIR}/ollama)
|
||||
endif()
|
||||
endif()
|
||||
if(NOT IS_ABSOLUTE "${OLLAMA_GO_OUTPUT}")
|
||||
set(OLLAMA_GO_OUTPUT "${CMAKE_SOURCE_DIR}/${OLLAMA_GO_OUTPUT}")
|
||||
endif()
|
||||
get_filename_component(OLLAMA_GO_OUTPUT "${OLLAMA_GO_OUTPUT}" ABSOLUTE)
|
||||
set(OLLAMA_GO_OUTPUT "${OLLAMA_GO_OUTPUT}" CACHE FILEPATH "Output path for the local Ollama Go binary")
|
||||
get_filename_component(OLLAMA_GO_OUTPUT_DIR "${OLLAMA_GO_OUTPUT}" DIRECTORY)
|
||||
|
||||
set(OLLAMA_GO_LDFLAGS
|
||||
"-s -w -X=github.com/ollama/ollama/version.Version=${OLLAMA_VERSION} -X=github.com/ollama/ollama/server.mode=release")
|
||||
if(GO_EXECUTABLE)
|
||||
add_custom_target(ollama-go ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${OLLAMA_GO_OUTPUT_DIR}"
|
||||
COMMAND ${CMAKE_COMMAND} -E env CGO_ENABLED=1
|
||||
${GO_EXECUTABLE} build -trimpath -ldflags "${OLLAMA_GO_LDFLAGS}" -o "${OLLAMA_GO_OUTPUT}" .
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
BYPRODUCTS ${OLLAMA_GO_OUTPUT}
|
||||
COMMENT "Building Ollama Go binary"
|
||||
VERBATIM)
|
||||
else()
|
||||
add_custom_target(ollama-go ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E echo
|
||||
"Go executable not found. Install Go or set GO_EXECUTABLE to build the local Ollama binary."
|
||||
COMMAND ${CMAKE_COMMAND} -E false
|
||||
COMMENT "Building Ollama Go binary"
|
||||
VERBATIM)
|
||||
endif()
|
||||
|
||||
set(_cpu_args)
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
list(APPEND _cpu_args
|
||||
-DBUILD_SHARED_LIBS=OFF
|
||||
-DGGML_BACKEND_DL=OFF
|
||||
-DGGML_METAL=ON
|
||||
-DGGML_METAL_EMBED_LIBRARY=ON)
|
||||
else()
|
||||
list(APPEND _cpu_args
|
||||
-DBUILD_SHARED_LIBS=ON
|
||||
-DGGML_BACKEND_DL=ON
|
||||
-DGGML_CPU_ALL_VARIANTS=ON)
|
||||
if(WIN32)
|
||||
list(APPEND _cpu_args -DGGML_OPENMP=ON)
|
||||
endif()
|
||||
if(APPLE)
|
||||
list(APPEND _cpu_args -DGGML_METAL=OFF)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
ollama_add_llama_server_build(local
|
||||
RUNNER_DIR ""
|
||||
TARGETS llama-server llama-quantize
|
||||
CMAKE_ARGS ${_cpu_args})
|
||||
|
||||
add_custom_target(ollama-local ALL
|
||||
DEPENDS ollama-go ollama-llama-server-local
|
||||
COMMENT "Building local Ollama payload")
|
||||
|
||||
install(PROGRAMS "${OLLAMA_GO_OUTPUT}"
|
||||
DESTINATION "${CMAKE_INSTALL_BINDIR}"
|
||||
COMPONENT ollama-local)
|
||||
endif()
|
||||
|
||||
set(_backend_targets)
|
||||
if(OLLAMA_HAVE_LLAMA_SERVER)
|
||||
foreach(_backend IN LISTS OLLAMA_LLAMA_BACKENDS)
|
||||
if(_backend STREQUAL "cuda_v12")
|
||||
ollama_llama_cuda_preset(${_backend} _cuda_preset)
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "cuda_v13")
|
||||
ollama_llama_cuda_preset(${_backend} _cuda_preset)
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "rocm_v7_1" OR _backend STREQUAL "rocm_v7_2")
|
||||
# ROCm 7.1 and 7.2 currently share build settings. Keep the backend
|
||||
# names versioned so future packaging can install side-by-side ROCm
|
||||
# payloads without changing the superbuild interface.
|
||||
ollama_rocm_preset(${_backend} _rocm_preset)
|
||||
set(_rocm_args
|
||||
-DBUILD_SHARED_LIBS=ON
|
||||
-DGGML_BACKEND_DL=ON
|
||||
-DGGML_HIP=ON
|
||||
-DCMAKE_HIP_PLATFORM=amd
|
||||
-DOLLAMA_GPU_BACKEND=hip)
|
||||
ollama_append_cache_arg_if_set(_rocm_args AMDGPU_TARGETS)
|
||||
ollama_append_cache_arg_if_set(_rocm_args CMAKE_HIP_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_rocm_args CMAKE_HIP_FLAGS)
|
||||
ollama_append_cache_arg_if_set(_rocm_args CMAKE_PREFIX_PATH)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_rocm_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-hip
|
||||
CMAKE_ARGS ${_rocm_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "vulkan")
|
||||
ollama_add_llama_server_build(vulkan
|
||||
RUNNER_DIR vulkan
|
||||
TARGETS ggml-vulkan
|
||||
CMAKE_ARGS
|
||||
-DBUILD_SHARED_LIBS=ON
|
||||
-DGGML_BACKEND_DL=ON
|
||||
-DGGML_VULKAN=ON
|
||||
-DOLLAMA_GPU_BACKEND=vulkan)
|
||||
list(APPEND _backend_targets ollama-llama-server-vulkan)
|
||||
elseif(_backend STREQUAL "cuda_jetpack5")
|
||||
if(CMAKE_CUDA_ARCHITECTURES)
|
||||
set(_cuda_preset llama_cuda_jetpack5_user_arch)
|
||||
else()
|
||||
set(_cuda_preset llama_cuda_jetpack5)
|
||||
endif()
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "cuda_jetpack6")
|
||||
if(CMAKE_CUDA_ARCHITECTURES)
|
||||
set(_cuda_preset llama_cuda_jetpack6_user_arch)
|
||||
else()
|
||||
set(_cuda_preset llama_cuda_jetpack6)
|
||||
endif()
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"Unknown OLLAMA_LLAMA_BACKENDS entry '${_backend}'")
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
if(_backend_targets)
|
||||
add_custom_target(ollama-llama-server-backends ALL
|
||||
DEPENDS ${_backend_targets}
|
||||
COMMENT "Building llama-server GPU backends")
|
||||
endif()
|
||||
|
||||
set(_mlx_targets)
|
||||
foreach(_backend IN LISTS OLLAMA_MLX_BACKENDS)
|
||||
if(_backend STREQUAL "cuda_v13")
|
||||
ollama_mlx_cuda_preset(_mlx_cuda_preset)
|
||||
set(_mlx_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_mlx_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_mlx_cuda_args MLX_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_mlx_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_mlx_build(cuda_v13
|
||||
PRESET ${_mlx_cuda_preset}
|
||||
RUNNER_DIR mlx_cuda_v13
|
||||
CMAKE_ARGS ${_mlx_cuda_args})
|
||||
list(APPEND _mlx_targets ollama-mlx-cuda_v13)
|
||||
elseif(_backend STREQUAL "metal_v3")
|
||||
if(NOT APPLE)
|
||||
message(FATAL_ERROR "OLLAMA_MLX_BACKENDS=metal_v3 is only supported on macOS")
|
||||
endif()
|
||||
ollama_check_metal_toolchain(_metal_version)
|
||||
ollama_add_mlx_build(metal_v3
|
||||
PRESET mlx_metal_v3
|
||||
RUNNER_DIR mlx_metal_v3)
|
||||
list(APPEND _mlx_targets ollama-mlx-metal_v3)
|
||||
elseif(_backend STREQUAL "metal_v4")
|
||||
if(NOT APPLE)
|
||||
message(FATAL_ERROR "OLLAMA_MLX_BACKENDS=metal_v4 is only supported on macOS")
|
||||
endif()
|
||||
ollama_check_metal_toolchain(_metal_version)
|
||||
ollama_macos_sdk_major_version(_ollama_mlx_sdk_major)
|
||||
if(_ollama_mlx_sdk_major AND _ollama_mlx_sdk_major GREATER_EQUAL 26)
|
||||
ollama_add_mlx_build(metal_v4
|
||||
PRESET mlx_metal_v4
|
||||
RUNNER_DIR mlx_metal_v4)
|
||||
list(APPEND _mlx_targets ollama-mlx-metal_v4)
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"OLLAMA_MLX_BACKENDS=metal_v4 requires the macOS 26 SDK. "
|
||||
"Install a newer Xcode or use OLLAMA_MLX_BACKENDS=metal_v3.")
|
||||
endif()
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"Unknown OLLAMA_MLX_BACKENDS entry '${_backend}'")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(_mlx_targets)
|
||||
add_custom_target(ollama-mlx-backends ALL
|
||||
DEPENDS ${_mlx_targets}
|
||||
COMMENT "Building MLX backends")
|
||||
endif()
|
||||
|
||||
install(DIRECTORY "${OLLAMA_PAYLOAD_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}/"
|
||||
DESTINATION "${OLLAMA_LIB_DIR}"
|
||||
COMPONENT ollama-local
|
||||
USE_SOURCE_PERMISSIONS)
|
||||
@@ -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 ")
|
||||
+124
-19
@@ -18,6 +18,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
@@ -41,6 +42,7 @@ import (
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
"github.com/ollama/ollama/cmd/tui"
|
||||
"github.com/ollama/ollama/discover"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/format"
|
||||
"github.com/ollama/ollama/internal/modelref"
|
||||
@@ -232,9 +234,6 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
// This gates both safetensors LLM and imagegen model creation
|
||||
experimental, _ := cmd.Flags().GetBool("experimental")
|
||||
draftQuantize, _ := cmd.Flags().GetString("draft-quantize")
|
||||
if draftQuantize != "" && !experimental {
|
||||
return errors.New("--draft-quantize requires --experimental")
|
||||
}
|
||||
if experimental {
|
||||
if !isLocalhost() {
|
||||
return errors.New("remote safetensor model creation not yet supported")
|
||||
@@ -329,6 +328,12 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
if quantize != "" {
|
||||
req.Quantize = quantize
|
||||
}
|
||||
if draftQuantize != "" {
|
||||
if len(req.DraftFiles) == 0 {
|
||||
return errors.New("--draft-quantize requires a DRAFT model")
|
||||
}
|
||||
req.DraftQuantize = draftQuantize
|
||||
}
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
@@ -339,29 +344,40 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
g.SetLimit(max(runtime.GOMAXPROCS(0)-1, 1))
|
||||
|
||||
files := syncmap.NewSyncMap[string, string]()
|
||||
fileNames := createRequestFileNames(req.Files)
|
||||
for f, digest := range req.Files {
|
||||
g.Go(func() error {
|
||||
if _, err := createBlob(cmd, client, f, digest, p); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: this is incorrect since the file might be in a subdirectory
|
||||
// instead this should take the path relative to the model directory
|
||||
// but the current implementation does not allow this
|
||||
files.Store(filepath.Base(f), digest)
|
||||
files.Store(fileNames[f], digest)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
adapters := syncmap.NewSyncMap[string, string]()
|
||||
adapterNames := createRequestFileNames(req.Adapters)
|
||||
for f, digest := range req.Adapters {
|
||||
g.Go(func() error {
|
||||
if _, err := createBlob(cmd, client, f, digest, p); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: same here
|
||||
adapters.Store(filepath.Base(f), digest)
|
||||
adapters.Store(adapterNames[f], digest)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
draftFiles := syncmap.NewSyncMap[string, string]()
|
||||
draftFileNames := createRequestFileNames(req.DraftFiles)
|
||||
for f, digest := range req.DraftFiles {
|
||||
g.Go(func() error {
|
||||
if _, err := createBlob(cmd, client, f, digest, p); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
draftFiles.Store(draftFileNames[f], digest)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -372,6 +388,7 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
|
||||
req.Files = files.Items()
|
||||
req.Adapters = adapters.Items()
|
||||
req.DraftFiles = draftFiles.Items()
|
||||
|
||||
bars := make(map[string]*progress.Bar)
|
||||
fn := func(resp api.ProgressResponse) error {
|
||||
@@ -409,6 +426,65 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func createRequestFileNames(files map[string]string) map[string]string {
|
||||
names := make(map[string]string, len(files))
|
||||
root, ok := commonFileRoot(files)
|
||||
for f := range files {
|
||||
name := filepath.Base(f)
|
||||
if ok {
|
||||
abs, err := filepath.Abs(f)
|
||||
if err == nil {
|
||||
if rel, err := filepath.Rel(root, abs); err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
name = rel
|
||||
}
|
||||
}
|
||||
}
|
||||
names[f] = path.Clean(filepath.ToSlash(name))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func commonFileRoot(files map[string]string) (string, bool) {
|
||||
if len(files) < 2 {
|
||||
return "", false
|
||||
}
|
||||
|
||||
var root string
|
||||
var volume string
|
||||
for f := range files {
|
||||
abs, err := filepath.Abs(f)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
if nextVolume := filepath.VolumeName(abs); volume == "" {
|
||||
volume = nextVolume
|
||||
} else if !strings.EqualFold(volume, nextVolume) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
dir := filepath.Dir(abs)
|
||||
if root == "" {
|
||||
root = dir
|
||||
continue
|
||||
}
|
||||
|
||||
for {
|
||||
rel, err := filepath.Rel(root, dir)
|
||||
if err == nil && (rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))) {
|
||||
break
|
||||
}
|
||||
|
||||
parent := filepath.Dir(root)
|
||||
if parent == root {
|
||||
return "", false
|
||||
}
|
||||
root = parent
|
||||
}
|
||||
}
|
||||
|
||||
return root, root != ""
|
||||
}
|
||||
|
||||
func createBlob(cmd *cobra.Command, client *api.Client, path string, digest string, p *progress.Progress) (string, error) {
|
||||
realPath, err := filepath.EvalSymlinks(path)
|
||||
if err != nil {
|
||||
@@ -1277,11 +1353,28 @@ func showInfo(resp *api.ShowResponse, verbose bool, w io.Writer) error {
|
||||
|
||||
if resp.ProjectorInfo != nil {
|
||||
tableRender("Projector", func() (rows [][]string) {
|
||||
arch := resp.ProjectorInfo["general.architecture"].(string)
|
||||
rows = append(rows, []string{"", "architecture", arch})
|
||||
rows = append(rows, []string{"", "parameters", format.HumanNumber(uint64(resp.ProjectorInfo["general.parameter_count"].(float64)))})
|
||||
rows = append(rows, []string{"", "embedding length", strconv.FormatFloat(resp.ProjectorInfo[fmt.Sprintf("%s.vision.embedding_length", arch)].(float64), 'f', -1, 64)})
|
||||
rows = append(rows, []string{"", "dimensions", strconv.FormatFloat(resp.ProjectorInfo[fmt.Sprintf("%s.vision.projection_dim", arch)].(float64), 'f', -1, 64)})
|
||||
arch, _ := resp.ProjectorInfo["general.architecture"].(string)
|
||||
if arch != "" {
|
||||
rows = append(rows, []string{"", "architecture", arch})
|
||||
}
|
||||
if v, ok := resp.ProjectorInfo["general.parameter_count"].(float64); ok {
|
||||
rows = append(rows, []string{"", "parameters", format.HumanNumber(uint64(v))})
|
||||
}
|
||||
|
||||
projectorValue := func(suffix string) (float64, bool) {
|
||||
for _, modality := range []string{"vision", "audio"} {
|
||||
if v, ok := resp.ProjectorInfo[fmt.Sprintf("%s.%s.%s", arch, modality, suffix)].(float64); ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if v, ok := projectorValue("embedding_length"); ok {
|
||||
rows = append(rows, []string{"", "embedding length", strconv.FormatFloat(v, 'f', -1, 64)})
|
||||
}
|
||||
if v, ok := projectorValue("projection_dim"); ok {
|
||||
rows = append(rows, []string{"", "dimensions", strconv.FormatFloat(v, 'f', -1, 64)})
|
||||
}
|
||||
return
|
||||
})
|
||||
}
|
||||
@@ -2009,7 +2102,7 @@ func appendEnvDocs(cmd *cobra.Command, envs []envconfig.EnvVar) {
|
||||
Environment Variables:
|
||||
`
|
||||
for _, e := range envs {
|
||||
envUsage += fmt.Sprintf(" %-24s %s\n", e.Name, e.Description)
|
||||
envUsage += fmt.Sprintf(" %-27s %s\n", e.Name, e.Description)
|
||||
}
|
||||
|
||||
cmd.SetUsageTemplate(cmd.UsageTemplate() + envUsage)
|
||||
@@ -2231,7 +2324,7 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
||||
|
||||
func launcherActionExitsLoop(integration string) bool {
|
||||
switch integration {
|
||||
case "vscode":
|
||||
case "codex-app", "vscode":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -2277,9 +2370,6 @@ func NewCLI() *cobra.Command {
|
||||
if experimental, _ := cmd.Flags().GetBool("experimental"); experimental {
|
||||
return nil
|
||||
}
|
||||
if draftQuantize, _ := cmd.Flags().GetString("draft-quantize"); draftQuantize != "" {
|
||||
return errors.New("--draft-quantize requires --experimental")
|
||||
}
|
||||
return checkServerHeartbeat(cmd, args)
|
||||
},
|
||||
RunE: CreateHandler,
|
||||
@@ -2445,6 +2535,16 @@ func NewCLI() *cobra.Command {
|
||||
_ = runner.Execute(args[1:])
|
||||
})
|
||||
|
||||
var gpuDiscoverLibDirs []string
|
||||
gpuDiscoverCmd := &cobra.Command{
|
||||
Use: "gpu-discover",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return discover.RunNativeProbeCommand(cmd.Context(), gpuDiscoverLibDirs, os.Stdout)
|
||||
},
|
||||
}
|
||||
gpuDiscoverCmd.Flags().StringArrayVar(&gpuDiscoverLibDirs, "lib-dir", nil, "Ollama runtime library directory")
|
||||
|
||||
envVars := envconfig.AsMap()
|
||||
|
||||
envs := []envconfig.EnvVar{envVars["OLLAMA_HOST"]}
|
||||
@@ -2473,6 +2573,7 @@ func NewCLI() *cobra.Command {
|
||||
envVars["OLLAMA_CONTEXT_LENGTH"],
|
||||
envVars["OLLAMA_KEEP_ALIVE"],
|
||||
envVars["OLLAMA_MAX_LOADED_MODELS"],
|
||||
envVars["OLLAMA_MAX_TRANSFER_STREAMS"],
|
||||
envVars["OLLAMA_MAX_QUEUE"],
|
||||
envVars["OLLAMA_MODELS"],
|
||||
envVars["OLLAMA_NUM_PARALLEL"],
|
||||
@@ -2484,6 +2585,9 @@ func NewCLI() *cobra.Command {
|
||||
envVars["OLLAMA_KV_CACHE_TYPE"],
|
||||
envVars["OLLAMA_LLM_LIBRARY"],
|
||||
envVars["OLLAMA_GPU_OVERHEAD"],
|
||||
envVars["OLLAMA_IGPU_ENABLE"],
|
||||
envVars["LLAMA_ARG_FIT"],
|
||||
envVars["LLAMA_ARG_FIT_TARGET"],
|
||||
envVars["OLLAMA_LOAD_TIMEOUT"],
|
||||
})
|
||||
default:
|
||||
@@ -2508,6 +2612,7 @@ func NewCLI() *cobra.Command {
|
||||
copyCmd,
|
||||
deleteCmd,
|
||||
runnerCmd,
|
||||
gpuDiscoverCmd,
|
||||
launch.LaunchCmd(checkServerHeartbeat, runInteractiveTUI),
|
||||
)
|
||||
|
||||
|
||||
@@ -249,7 +249,7 @@ func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) {
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(context.Background())
|
||||
|
||||
for _, integration := range []string{"vscode"} {
|
||||
for _, integration := range []string{"codex-app", "vscode"} {
|
||||
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: integration}, launcherDeps{
|
||||
resolveRunModel: unexpectedRunModelResolution(t),
|
||||
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||
|
||||
+44
-13
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ func (c *Claude) findPath() (string, error) {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func (c *Claude) Run(model string, args []string) error {
|
||||
func (c *Claude) Run(model string, _ []LaunchModel, args []string) error {
|
||||
claudePath, err := c.findPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("claude is not installed, install from https://code.claude.com/docs/en/quickstart")
|
||||
|
||||
@@ -130,7 +130,7 @@ func (c *ClaudeDesktop) SkipModelReadiness() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) Run(_ string, _ []string) error {
|
||||
func (c *ClaudeDesktop) Run(_ string, _ []LaunchModel, _ []string) error {
|
||||
return errClaudeDesktopUnsupported()
|
||||
}
|
||||
|
||||
|
||||
@@ -932,7 +932,7 @@ func TestClaudeDesktopRunReturnsUnsupported(t *testing.T) {
|
||||
)
|
||||
|
||||
for _, args := range [][]string{nil, {"--foo"}} {
|
||||
err := (&ClaudeDesktop{}).Run("qwen3.5", args)
|
||||
err := (&ClaudeDesktop{}).Run("qwen3.5", nil, args)
|
||||
if err == nil {
|
||||
t.Fatal("expected Run to fail")
|
||||
}
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@ type Cline struct{}
|
||||
|
||||
func (c *Cline) String() string { return "Cline" }
|
||||
|
||||
func (c *Cline) Run(model string, args []string) error {
|
||||
func (c *Cline) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if _, err := exec.LookPath("cline"); err != nil {
|
||||
return fmt.Errorf("cline is not installed, install with: npm install -g cline")
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func (c *Cline) Paths() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cline) Edit(models []string) error {
|
||||
func (c *Cline) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -66,10 +66,10 @@ func (c *Cline) Edit(models []string) error {
|
||||
baseURL := envconfig.Host().String()
|
||||
config["ollamaBaseUrl"] = baseURL
|
||||
config["actModeApiProvider"] = "ollama"
|
||||
config["actModeOllamaModelId"] = models[0]
|
||||
config["actModeOllamaModelId"] = models[0].Name
|
||||
config["actModeOllamaBaseUrl"] = baseURL
|
||||
config["planModeApiProvider"] = "ollama"
|
||||
config["planModeOllamaModelId"] = models[0]
|
||||
config["planModeOllamaModelId"] = models[0].Name
|
||||
config["planModeOllamaBaseUrl"] = baseURL
|
||||
|
||||
config["welcomeViewCompleted"] = true
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestClineEdit(t *testing.T) {
|
||||
t.Run("creates config from scratch", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
|
||||
if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ func TestClineEdit(t *testing.T) {
|
||||
data, _ := json.Marshal(existing)
|
||||
os.WriteFile(configPath, data, 0o644)
|
||||
|
||||
if err := c.Edit([]string{"glm-5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -93,10 +93,10 @@ func TestClineEdit(t *testing.T) {
|
||||
t.Run("updates model on re-edit", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
|
||||
if err := c.Edit([]string{"kimi-k2.5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := c.Edit([]string{"glm-5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -124,7 +124,7 @@ func TestClineEdit(t *testing.T) {
|
||||
t.Run("uses first model as primary", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
|
||||
if err := c.Edit([]string{"kimi-k2.5:cloud", "glm-5:cloud"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud", "glm-5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
+571
-48
@@ -1,13 +1,17 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
"golang.org/x/mod/semver"
|
||||
)
|
||||
|
||||
@@ -16,10 +20,22 @@ type Codex struct{}
|
||||
|
||||
func (c *Codex) String() string { return "Codex" }
|
||||
|
||||
const codexProfileName = "ollama-launch"
|
||||
const (
|
||||
codexProfileName = "ollama-launch"
|
||||
codexProviderName = "Ollama"
|
||||
codexFallbackContextWindow = 128_000
|
||||
|
||||
func (c *Codex) args(model string, extra []string) []string {
|
||||
codexRootProfileKey = "profile"
|
||||
codexRootModelKey = "model"
|
||||
codexRootModelProviderKey = "model_provider"
|
||||
codexRootModelCatalogJSONKey = "model_catalog_json"
|
||||
)
|
||||
|
||||
func (c *Codex) args(model, modelCatalogPath string, extra []string) []string {
|
||||
args := []string{"--profile", codexProfileName}
|
||||
if modelCatalogPath != "" {
|
||||
args = append(args, "-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
|
||||
}
|
||||
if model != "" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
@@ -27,16 +43,21 @@ func (c *Codex) args(model string, extra []string) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func (c *Codex) Run(model string, args []string) error {
|
||||
func (c *Codex) Run(model string, models []LaunchModel, args []string) error {
|
||||
if err := checkCodexVersion(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ensureCodexConfig(); err != nil {
|
||||
if err := ensureCodexConfig(model, models); err != nil {
|
||||
return fmt.Errorf("failed to configure codex: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("codex", c.args(model, args)...)
|
||||
catalogPath, err := codexModelCatalogPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to configure codex: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("codex", c.args(model, catalogPath, args)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -46,79 +67,581 @@ func (c *Codex) Run(model string, args []string) error {
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// ensureCodexConfig writes a [profiles.ollama-launch] section to ~/.codex/config.toml
|
||||
// with openai_base_url pointing to the local Ollama server.
|
||||
func ensureCodexConfig() error {
|
||||
home, err := os.UserHomeDir()
|
||||
// ensureCodexConfig writes a Codex profile and model catalog so Codex uses the
|
||||
// local Ollama server and has model metadata available.
|
||||
func ensureCodexConfig(modelName string, models []LaunchModel) error {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
codexDir := filepath.Join(home, ".codex")
|
||||
codexDir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(codexDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(codexDir, "config.toml")
|
||||
return writeCodexProfile(configPath)
|
||||
catalogPath := codexModelCatalogPathForConfig(configPath)
|
||||
if err := writeCodexModelCatalog(catalogPath, codexCatalogModel(modelName, models)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeCodexProfile(configPath, catalogPath)
|
||||
}
|
||||
|
||||
func codexConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".codex", "config.toml"), nil
|
||||
}
|
||||
|
||||
func codexModelCatalogPath() (string, error) {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return codexModelCatalogPathForConfig(configPath), nil
|
||||
}
|
||||
|
||||
func codexModelCatalogPathForConfig(configPath string) string {
|
||||
return filepath.Join(filepath.Dir(configPath), "model.json")
|
||||
}
|
||||
|
||||
// writeCodexProfile ensures ~/.codex/config.toml has the ollama-launch profile
|
||||
// and model provider sections with the correct base URL.
|
||||
func writeCodexProfile(configPath string) error {
|
||||
baseURL := envconfig.Host().String() + "/v1/"
|
||||
func writeCodexProfile(configPath string, modelCatalogPath ...string) error {
|
||||
opts := codexLaunchProfileOptions{
|
||||
forceAPIAuth: true,
|
||||
}
|
||||
if len(modelCatalogPath) > 0 {
|
||||
opts.modelCatalogPath = modelCatalogPath[0]
|
||||
}
|
||||
return writeCodexLaunchProfile(configPath, opts)
|
||||
}
|
||||
|
||||
type codexLaunchProfileOptions struct {
|
||||
activate bool
|
||||
profileName string
|
||||
forceAPIAuth bool
|
||||
setRootModelConfig bool
|
||||
model string
|
||||
modelCatalogPath string
|
||||
backupIntegration string
|
||||
}
|
||||
|
||||
func writeCodexLaunchProfile(configPath string, opts codexLaunchProfileOptions) error {
|
||||
baseURL := codexBaseURL()
|
||||
profileName := codexLaunchProfileName(opts)
|
||||
profileHeader := codexProfileHeaderFor(profileName)
|
||||
providerHeader := codexProviderHeaderFor(profileName)
|
||||
|
||||
content, readErr := os.ReadFile(configPath)
|
||||
text := ""
|
||||
if readErr == nil {
|
||||
text = string(content)
|
||||
} else if !os.IsNotExist(readErr) {
|
||||
return readErr
|
||||
}
|
||||
parsed, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
model := strings.TrimSpace(opts.model)
|
||||
if model == "" {
|
||||
model = parsed.ProfileString(profileName, codexRootModelKey)
|
||||
}
|
||||
modelCatalogPath := strings.TrimSpace(opts.modelCatalogPath)
|
||||
if modelCatalogPath == "" {
|
||||
modelCatalogPath = parsed.ProfileString(profileName, codexRootModelCatalogJSONKey)
|
||||
}
|
||||
|
||||
profileLines := []string{}
|
||||
if model != "" {
|
||||
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelKey, model))
|
||||
}
|
||||
profileLines = append(profileLines,
|
||||
fmt.Sprintf("openai_base_url = %q", baseURL),
|
||||
fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName),
|
||||
)
|
||||
if opts.forceAPIAuth {
|
||||
profileLines = append(profileLines, `forced_login_method = "api"`)
|
||||
}
|
||||
if modelCatalogPath != "" {
|
||||
profileLines = append(profileLines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath))
|
||||
}
|
||||
|
||||
sections := []struct {
|
||||
header string
|
||||
lines []string
|
||||
}{
|
||||
{
|
||||
header: fmt.Sprintf("[profiles.%s]", codexProfileName),
|
||||
lines: []string{
|
||||
fmt.Sprintf("openai_base_url = %q", baseURL),
|
||||
`forced_login_method = "api"`,
|
||||
fmt.Sprintf("model_provider = %q", codexProfileName),
|
||||
},
|
||||
header: profileHeader,
|
||||
lines: profileLines,
|
||||
},
|
||||
{
|
||||
header: fmt.Sprintf("[model_providers.%s]", codexProfileName),
|
||||
header: providerHeader,
|
||||
lines: []string{
|
||||
`name = "Ollama"`,
|
||||
fmt.Sprintf("name = %q", codexProviderName),
|
||||
fmt.Sprintf("base_url = %q", baseURL),
|
||||
`wire_api = "responses"`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
content, readErr := os.ReadFile(configPath)
|
||||
text := ""
|
||||
if readErr == nil {
|
||||
text = string(content)
|
||||
if opts.activate {
|
||||
text = codexSetRootStringValue(text, codexRootProfileKey, profileName)
|
||||
}
|
||||
|
||||
for _, s := range sections {
|
||||
block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n"
|
||||
|
||||
if idx := strings.Index(text, s.header); idx >= 0 {
|
||||
// Replace the existing section up to the next section header.
|
||||
rest := text[idx+len(s.header):]
|
||||
if endIdx := strings.Index(rest, "\n["); endIdx >= 0 {
|
||||
text = text[:idx] + block + rest[endIdx+1:]
|
||||
} else {
|
||||
text = text[:idx] + block
|
||||
}
|
||||
} else {
|
||||
// Append the section.
|
||||
if text != "" && !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += block
|
||||
if opts.setRootModelConfig {
|
||||
if model != "" {
|
||||
text = codexSetRootStringValue(text, codexRootModelKey, model)
|
||||
}
|
||||
text = codexSetRootStringValue(text, codexRootModelProviderKey, profileName)
|
||||
if modelCatalogPath != "" {
|
||||
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, []byte(text), 0o644)
|
||||
for _, s := range sections {
|
||||
text = codexUpsertSection(text, s.header, s.lines)
|
||||
}
|
||||
parsed, err = codexParseConfig(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := codexValidateLaunchProfileText(parsed, profileName, opts, model, modelCatalogPath, baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, []byte(text), opts.backupIntegration)
|
||||
}
|
||||
|
||||
func codexLaunchProfileName(opts codexLaunchProfileOptions) string {
|
||||
if name := strings.TrimSpace(opts.profileName); name != "" {
|
||||
return name
|
||||
}
|
||||
return codexProfileName
|
||||
}
|
||||
|
||||
func codexBaseURL() string {
|
||||
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1/"
|
||||
}
|
||||
|
||||
func codexProfileHeader() string {
|
||||
return codexProfileHeaderFor(codexProfileName)
|
||||
}
|
||||
|
||||
func codexProviderHeader() string {
|
||||
return codexProviderHeaderFor(codexProfileName)
|
||||
}
|
||||
|
||||
func codexProfileHeaderFor(profileName string) string {
|
||||
return fmt.Sprintf("[profiles.%s]", profileName)
|
||||
}
|
||||
|
||||
func codexProviderHeaderFor(profileName string) string {
|
||||
return fmt.Sprintf("[model_providers.%s]", profileName)
|
||||
}
|
||||
|
||||
func codexValidateLaunchProfileText(config codexParsedConfig, profileName string, opts codexLaunchProfileOptions, model, modelCatalogPath, baseURL string) error {
|
||||
for _, check := range []struct {
|
||||
path []string
|
||||
want string
|
||||
}{
|
||||
{[]string{"profiles", profileName, "openai_base_url"}, baseURL},
|
||||
{[]string{"profiles", profileName, codexRootModelProviderKey}, profileName},
|
||||
{[]string{"model_providers", profileName, "name"}, codexProviderName},
|
||||
{[]string{"model_providers", profileName, "base_url"}, baseURL},
|
||||
{[]string{"model_providers", profileName, "wire_api"}, "responses"},
|
||||
} {
|
||||
if got, ok := config.String(check.path...); !ok || got != check.want {
|
||||
return fmt.Errorf("generated Codex config missing %s = %q", strings.Join(check.path, "."), check.want)
|
||||
}
|
||||
}
|
||||
if opts.forceAPIAuth {
|
||||
if got, ok := config.String("profiles", profileName, "forced_login_method"); !ok || got != "api" {
|
||||
return fmt.Errorf("generated Codex config missing profiles.%s.forced_login_method = %q", profileName, "api")
|
||||
}
|
||||
}
|
||||
if model != "" {
|
||||
if got, ok := config.String("profiles", profileName, codexRootModelKey); !ok || got != model {
|
||||
return fmt.Errorf("generated Codex config missing profiles.%s.model = %q", profileName, model)
|
||||
}
|
||||
}
|
||||
if modelCatalogPath != "" {
|
||||
if got, ok := config.String("profiles", profileName, codexRootModelCatalogJSONKey); !ok || got != modelCatalogPath {
|
||||
return fmt.Errorf("generated Codex config missing profiles.%s.model_catalog_json = %q", profileName, modelCatalogPath)
|
||||
}
|
||||
}
|
||||
if opts.activate {
|
||||
if got := config.RootString(codexRootProfileKey); got != profileName {
|
||||
return fmt.Errorf("generated Codex config missing profile = %q", profileName)
|
||||
}
|
||||
}
|
||||
if opts.setRootModelConfig {
|
||||
if model != "" {
|
||||
if got := config.RootString(codexRootModelKey); got != model {
|
||||
return fmt.Errorf("generated Codex config missing model = %q", model)
|
||||
}
|
||||
}
|
||||
if got := config.RootString(codexRootModelProviderKey); got != profileName {
|
||||
return fmt.Errorf("generated Codex config missing model_provider = %q", profileName)
|
||||
}
|
||||
if modelCatalogPath != "" {
|
||||
if got := config.RootString(codexRootModelCatalogJSONKey); got != modelCatalogPath {
|
||||
return fmt.Errorf("generated Codex config missing model_catalog_json = %q", modelCatalogPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func codexUpsertSection(text, header string, lines []string) string {
|
||||
block := strings.Join(append([]string{header}, lines...), "\n") + "\n"
|
||||
|
||||
if targetPath, ok := codexTableHeaderPath(header); ok {
|
||||
if start, end, found := codexSectionRange(text, targetPath); found {
|
||||
return text[:start] + block + text[end:]
|
||||
}
|
||||
}
|
||||
|
||||
if text != "" && !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
return text + block
|
||||
}
|
||||
|
||||
func codexRemoveSection(text, header string) string {
|
||||
targetPath, ok := codexTableHeaderPath(header)
|
||||
if !ok {
|
||||
return text
|
||||
}
|
||||
start, end, found := codexSectionRange(text, targetPath)
|
||||
if !found {
|
||||
return text
|
||||
}
|
||||
return text[:start] + text[end:]
|
||||
}
|
||||
|
||||
type codexParsedConfig struct {
|
||||
values map[string]any
|
||||
}
|
||||
|
||||
func (c codexParsedConfig) String(path ...string) (string, bool) {
|
||||
if len(path) == 0 {
|
||||
return "", false
|
||||
}
|
||||
var current any = c.values
|
||||
for _, part := range path {
|
||||
table, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
current, ok = table[part]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
value, ok := current.(string)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func (c codexParsedConfig) RootString(key string) string {
|
||||
value, _ := c.RootStringOK(key)
|
||||
return value
|
||||
}
|
||||
|
||||
func (c codexParsedConfig) RootStringOK(key string) (string, bool) {
|
||||
return c.String(key)
|
||||
}
|
||||
|
||||
func (c codexParsedConfig) ProfileString(profileName, key string) string {
|
||||
value, _ := c.String("profiles", profileName, key)
|
||||
return value
|
||||
}
|
||||
|
||||
func (c codexParsedConfig) ProviderString(profileName, key string) string {
|
||||
value, _ := c.String("model_providers", profileName, key)
|
||||
return value
|
||||
}
|
||||
|
||||
func codexRootStringValue(text, key string) string {
|
||||
config, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return config.RootString(key)
|
||||
}
|
||||
|
||||
func codexRootStringValueOK(text, key string) (string, bool) {
|
||||
config, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return config.RootStringOK(key)
|
||||
}
|
||||
|
||||
func codexStringValue(text string, path ...string) (string, bool) {
|
||||
config, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return config.String(path...)
|
||||
}
|
||||
|
||||
func codexSectionStringValue(text, header, key string) string {
|
||||
path, ok := codexTableHeaderPath(header)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
value, _ := codexStringValue(text, append(path, key)...)
|
||||
return value
|
||||
}
|
||||
|
||||
func codexParseConfig(text string) (codexParsedConfig, error) {
|
||||
values, err := codexParseConfigText(text)
|
||||
if err != nil {
|
||||
return codexParsedConfig{}, err
|
||||
}
|
||||
return codexParsedConfig{values: values}, nil
|
||||
}
|
||||
|
||||
func codexParseConfigText(text string) (map[string]any, error) {
|
||||
cfg := map[string]any{}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return cfg, nil
|
||||
}
|
||||
if err := toml.Unmarshal([]byte(text), &cfg); err != nil {
|
||||
return nil, fmt.Errorf("invalid Codex config TOML: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func codexValidateConfigText(text string) error {
|
||||
_, err := codexParseConfig(text)
|
||||
return err
|
||||
}
|
||||
|
||||
func codexSectionRange(text string, targetPath []string) (int, int, bool) {
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
offset := 0
|
||||
start := -1
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "#") {
|
||||
offset += len(line)
|
||||
continue
|
||||
}
|
||||
if start >= 0 {
|
||||
return start, offset, true
|
||||
}
|
||||
if path, ok := codexTableHeaderPath(trimmed); ok && codexSamePath(path, targetPath) {
|
||||
start = offset
|
||||
}
|
||||
offset += len(line)
|
||||
}
|
||||
if start >= 0 {
|
||||
return start, len(text), true
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func codexTableHeaderPath(header string) ([]string, bool) {
|
||||
trimmed := strings.TrimSpace(header)
|
||||
if !strings.HasPrefix(trimmed, "[") || strings.HasPrefix(trimmed, "[[") {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
const probeKey = "__ollama_launch_probe"
|
||||
cfg := map[string]any{}
|
||||
if err := toml.Unmarshal([]byte(trimmed+"\n"+probeKey+" = true\n"), &cfg); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return codexFindProbePath(cfg, probeKey, nil)
|
||||
}
|
||||
|
||||
func codexFindProbePath(value any, probeKey string, path []string) ([]string, bool) {
|
||||
table, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if probe, ok := table[probeKey].(bool); ok && probe {
|
||||
return path, true
|
||||
}
|
||||
for key, child := range table {
|
||||
if key == probeKey {
|
||||
continue
|
||||
}
|
||||
if childPath, ok := codexFindProbePath(child, probeKey, append(path, key)); ok {
|
||||
return childPath, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func codexSamePath(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func codexSetRootStringValue(text, key, value string) string {
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
rootEnd := len(lines)
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "[") {
|
||||
rootEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
assignment := fmt.Sprintf("%s = %q", key, value)
|
||||
for i := range rootEnd {
|
||||
line := lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
|
||||
continue
|
||||
}
|
||||
if codexRootLineHasKey(trimmed, key) {
|
||||
if strings.HasSuffix(line, "\n") {
|
||||
lines[i] = assignment + "\n"
|
||||
} else {
|
||||
lines[i] = assignment
|
||||
}
|
||||
return strings.Join(lines, "")
|
||||
}
|
||||
}
|
||||
|
||||
insert := assignment + "\n"
|
||||
root := strings.Join(lines[:rootEnd], "")
|
||||
rest := strings.Join(lines[rootEnd:], "")
|
||||
if root != "" && !strings.HasSuffix(root, "\n") {
|
||||
root += "\n"
|
||||
}
|
||||
if rest != "" && !strings.HasSuffix(insert, "\n\n") {
|
||||
insert += "\n"
|
||||
}
|
||||
return root + insert + rest
|
||||
}
|
||||
|
||||
func codexRemoveRootValue(text, key string) string {
|
||||
lines := strings.SplitAfter(text, "\n")
|
||||
rootEnd := len(lines)
|
||||
for i, line := range lines {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "[") {
|
||||
rootEnd = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(lines))
|
||||
for i, line := range lines {
|
||||
if i < rootEnd {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" && !strings.HasPrefix(trimmed, "#") && codexRootLineHasKey(trimmed, key) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out = append(out, line)
|
||||
}
|
||||
return strings.Join(out, "")
|
||||
}
|
||||
|
||||
func codexRootLineHasKey(line, key string) bool {
|
||||
cfg := map[string]any{}
|
||||
if err := toml.Unmarshal([]byte(line+"\n"), &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
_, ok := cfg[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func codexCatalogModel(modelName string, models []LaunchModel) LaunchModel {
|
||||
if model, ok := findLaunchModel(models, modelName); ok {
|
||||
return model.WithCloudLimits()
|
||||
}
|
||||
return fallbackLaunchModel(modelName)
|
||||
}
|
||||
|
||||
func writeCodexModelCatalog(catalogPath string, model LaunchModel) error {
|
||||
entry := buildCodexModelEntry(model)
|
||||
|
||||
catalog := map[string]any{
|
||||
"models": []any{entry},
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(catalog, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(catalogPath, data, 0o644)
|
||||
}
|
||||
|
||||
func buildCodexModelEntry(launchModel LaunchModel) map[string]any {
|
||||
modelName := launchModel.Name
|
||||
contextWindow := codexFallbackContextWindow
|
||||
systemPrompt := ""
|
||||
|
||||
if launchModel.ContextLength > 0 {
|
||||
contextWindow = launchModel.ContextLength
|
||||
} else if launchModel.Details.ContextLength > 0 {
|
||||
contextWindow = launchModel.Details.ContextLength
|
||||
}
|
||||
if l, ok := lookupCloudModelLimit(modelName); ok {
|
||||
contextWindow = l.Context
|
||||
}
|
||||
|
||||
if !isCloudModelName(modelName) && launchModel.Details.Format != "safetensors" {
|
||||
if ctxLen := envconfig.ContextLength(); ctxLen > 0 {
|
||||
contextWindow = int(ctxLen)
|
||||
}
|
||||
}
|
||||
|
||||
modalities := []string{"text"}
|
||||
if launchModel.HasCapability(model.CapabilityVision) {
|
||||
modalities = append(modalities, "image")
|
||||
}
|
||||
|
||||
truncationMode := "bytes"
|
||||
if isCloudModelName(modelName) {
|
||||
truncationMode = "tokens"
|
||||
}
|
||||
|
||||
return map[string]any{
|
||||
"slug": modelName,
|
||||
"display_name": modelName,
|
||||
"context_window": contextWindow,
|
||||
"shell_type": "default",
|
||||
"visibility": "list",
|
||||
"supported_in_api": true,
|
||||
"priority": 0,
|
||||
"truncation_policy": map[string]any{"mode": truncationMode, "limit": 10000},
|
||||
"input_modalities": modalities,
|
||||
"base_instructions": systemPrompt,
|
||||
"support_verbosity": true,
|
||||
"default_verbosity": "low",
|
||||
"supports_parallel_tool_calls": false,
|
||||
"supports_reasoning_summaries": false,
|
||||
"supported_reasoning_levels": []any{},
|
||||
"experimental_supported_tools": []any{},
|
||||
}
|
||||
}
|
||||
|
||||
func checkCodexVersion() error {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+380
-14
@@ -1,15 +1,23 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestCodexArgs(t *testing.T) {
|
||||
c := &Codex{}
|
||||
catalogPath := filepath.Join("tmp", "model.json")
|
||||
catalogArg := fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -17,15 +25,15 @@ func TestCodexArgs(t *testing.T) {
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"with model", "llama3.2", nil, []string{"--profile", "ollama-launch", "-m", "llama3.2"}},
|
||||
{"empty model", "", nil, []string{"--profile", "ollama-launch"}},
|
||||
{"with model and extra args", "qwen3.5", []string{"-p", "myprofile"}, []string{"--profile", "ollama-launch", "-m", "qwen3.5", "-p", "myprofile"}},
|
||||
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--profile", "ollama-launch", "-m", "llama3.2", "--sandbox", "workspace-write"}},
|
||||
{"with model", "llama3.2", nil, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "llama3.2"}},
|
||||
{"empty model", "", nil, []string{"--profile", "ollama-launch", "-c", catalogArg}},
|
||||
{"with model and extra args", "qwen3.5", []string{"-p", "myprofile"}, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "qwen3.5", "-p", "myprofile"}},
|
||||
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"--profile", "ollama-launch", "-c", catalogArg, "-m", "llama3.2", "--sandbox", "workspace-write"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := c.args(tt.model, tt.args)
|
||||
got := c.args(tt.model, catalogPath, tt.args)
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
|
||||
}
|
||||
@@ -37,8 +45,9 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
t.Run("creates new file when none exists", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -63,21 +72,28 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
if !strings.Contains(content, `model_provider = "ollama-launch"`) {
|
||||
t.Error("missing model_provider key")
|
||||
}
|
||||
if !strings.Contains(content, fmt.Sprintf("model_catalog_json = %q", catalogPath)) {
|
||||
t.Error("missing model_catalog_json key")
|
||||
}
|
||||
if !strings.Contains(content, "[model_providers.ollama-launch]") {
|
||||
t.Error("missing [model_providers.ollama-launch] section")
|
||||
}
|
||||
if !strings.Contains(content, `name = "Ollama"`) {
|
||||
t.Error("missing model provider name")
|
||||
}
|
||||
if err := codexValidateConfigText(content); err != nil {
|
||||
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends profile to existing file without profile", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
existing := "[some_other_section]\nkey = \"value\"\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -95,10 +111,11 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
t.Run("replaces existing profile section", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n\n[model_providers.ollama-launch]\nname = \"Ollama\"\nbase_url = \"http://old:1234/v1/\"\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -114,15 +131,160 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [model_providers.ollama-launch] section, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
|
||||
}
|
||||
if err := codexValidateConfigText(content); err != nil {
|
||||
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaces equivalent quoted profile table", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
existing := "" +
|
||||
`profile = "default"` + "\n\n" +
|
||||
`[profiles."ollama-launch"]` + "\n" +
|
||||
`openai_base_url = "http://old:1234/v1/"` + "\n\n" +
|
||||
`[model_providers."ollama-launch"]` + "\n" +
|
||||
`name = "Old"` + "\n" +
|
||||
`base_url = "http://old:1234/v1/"` + "\n\n" +
|
||||
`[profiles.default]` + "\n" +
|
||||
`model = "gpt-5.5"` + "\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Contains(content, `profiles."ollama-launch"`) {
|
||||
t.Fatalf("quoted profile table should be replaced, got:\n%s", content)
|
||||
}
|
||||
if strings.Contains(content, "old:1234") {
|
||||
t.Fatalf("old URL was not replaced, got:\n%s", content)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProfileHeader(), "model_provider"); got != codexProfileName {
|
||||
t.Fatalf("profile model_provider = %q, want %q", got, codexProfileName)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
|
||||
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
|
||||
}
|
||||
if err := codexValidateConfigText(content); err != nil {
|
||||
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid existing toml without writing", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
existing := "profile = \n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
err := writeCodexProfile(configPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
||||
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
if string(data) != existing {
|
||||
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects malformed existing toml variants without writing", func(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"duplicate root key": "profile = \"default\"\nprofile = \"other\"\n",
|
||||
"unterminated string": "model = \"gpt-5.5\n",
|
||||
"bad table": "[profiles.ollama-launch\nmodel = \"llama3.2\"\n",
|
||||
"duplicate table key": "[profiles.ollama-launch]\nmodel = \"a\"\nmodel = \"b\"\n",
|
||||
}
|
||||
for name, existing := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := writeCodexProfile(configPath)
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
||||
t.Fatalf("writeCodexProfile error = %v, want invalid TOML", err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
if string(data) != existing {
|
||||
t.Fatalf("invalid config should be left untouched, got:\n%s", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("backs up previous config before overwrite", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := "# original-codex-backup-marker\n[profiles.default]\nmodel = \"gpt-5.5\"\n"
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), "original-codex-backup-marker")
|
||||
})
|
||||
|
||||
t.Run("updates equivalent quoted root keys", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
existing := "" +
|
||||
`"profile" = "default"` + "\n" +
|
||||
`"model" = "gpt-5.5"` + "\n" +
|
||||
`"model_provider" = "openai"` + "\n\n" +
|
||||
`[profiles.default]` + "\n" +
|
||||
`model = "gpt-5.5"` + "\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
err := writeCodexLaunchProfile(configPath, codexLaunchProfileOptions{
|
||||
activate: true,
|
||||
setRootModelConfig: true,
|
||||
model: "llama3.2",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
for key, want := range map[string]string{
|
||||
"profile": codexProfileName,
|
||||
"model": "llama3.2",
|
||||
"model_provider": codexProfileName,
|
||||
} {
|
||||
if got := codexRootStringValue(content, key); got != want {
|
||||
t.Fatalf("root %s = %q, want %q in:\n%s", key, got, want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(content, `"profile"`) || strings.Contains(content, `"model_provider"`) {
|
||||
t.Fatalf("quoted root keys should be rewritten once, got:\n%s", content)
|
||||
}
|
||||
if err := codexValidateConfigText(content); err != nil {
|
||||
t.Fatalf("generated config should be valid TOML: %v\n%s", err, content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaces profile while preserving following sections", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
existing := "[profiles.ollama-launch]\nopenai_base_url = \"http://old:1234/v1/\"\n[another_section]\nfoo = \"bar\"\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -143,10 +305,11 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
t.Run("appends newline to file not ending with newline", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
existing := "[other]\nkey = \"val\""
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -166,8 +329,9 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://myhost:9999")
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
if err := writeCodexProfile(configPath, catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -178,6 +342,26 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
t.Errorf("expected custom host in URL, got:\n%s", content)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses connectable host for unspecified bind address", func(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Contains(content, "0.0.0.0") {
|
||||
t.Fatalf("config should not write bind-only host, got:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "127.0.0.1:11434/v1/") {
|
||||
t.Fatalf("expected connectable loopback URL, got:\n%s", content)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnsureCodexConfig(t *testing.T) {
|
||||
@@ -185,7 +369,7 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
if err := ensureCodexConfig(); err != nil {
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -202,16 +386,25 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
if !strings.Contains(content, "openai_base_url") {
|
||||
t.Error("missing openai_base_url key")
|
||||
}
|
||||
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
data, err = os.ReadFile(catalogPath)
|
||||
if err != nil {
|
||||
t.Fatalf("model.json not created: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"slug": "llama3.2"`) {
|
||||
t.Error("missing model catalog entry for selected model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("is idempotent", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
if err := ensureCodexConfig(); err != nil {
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureCodexConfig(); err != nil {
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -227,3 +420,176 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func assertBackupContains(t *testing.T, pattern, marker string) {
|
||||
t.Helper()
|
||||
backups, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, backupPath := range backups {
|
||||
data, err := os.ReadFile(backupPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), marker) {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("backup matching %q with marker %q not found", pattern, marker)
|
||||
}
|
||||
|
||||
func TestModelInfoContextLength(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
modelInfo map[string]any
|
||||
want int
|
||||
}{
|
||||
{"float64 value", map[string]any{"qwen3_5_moe.context_length": float64(262144)}, 262144},
|
||||
{"int value", map[string]any{"llama.context_length": 131072}, 131072},
|
||||
{"no context_length key", map[string]any{"llama.embedding_length": float64(4096)}, 0},
|
||||
{"empty map", map[string]any{}, 0},
|
||||
{"nil map", nil, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, _ := modelInfoContextLength(tt.modelInfo)
|
||||
if got != tt.want {
|
||||
t.Errorf("modelInfoContextLength() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCodexModelEntryContextWindow(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model LaunchModel
|
||||
envContextLen string
|
||||
wantContext int
|
||||
}{
|
||||
{
|
||||
name: "inventory context length as fallback",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "gguf"},
|
||||
},
|
||||
wantContext: 131072,
|
||||
},
|
||||
{
|
||||
name: "details context length is used when model context is empty",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
Details: api.ModelDetails{Format: "gguf", ContextLength: 131072},
|
||||
},
|
||||
wantContext: 131072,
|
||||
},
|
||||
{
|
||||
name: "OLLAMA_CONTEXT_LENGTH overrides local gguf inventory context",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "gguf"},
|
||||
},
|
||||
envContextLen: "64000",
|
||||
wantContext: 64000,
|
||||
},
|
||||
{
|
||||
name: "safetensors uses inventory context only",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "safetensors"},
|
||||
},
|
||||
envContextLen: "64000",
|
||||
wantContext: 131072,
|
||||
},
|
||||
{
|
||||
name: "cloud model uses hardcoded limits",
|
||||
model: LaunchModel{
|
||||
Name: "qwen3.5:cloud",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "gguf"},
|
||||
},
|
||||
envContextLen: "64000",
|
||||
wantContext: 262144,
|
||||
},
|
||||
{
|
||||
name: "unknown cloud model without metadata uses fallback context",
|
||||
model: LaunchModel{
|
||||
Name: "deepseek-v4-pro:cloud",
|
||||
},
|
||||
envContextLen: "64000",
|
||||
wantContext: codexFallbackContextWindow,
|
||||
},
|
||||
{
|
||||
name: "vision capability without reasoning advertisement",
|
||||
model: LaunchModel{
|
||||
Name: "llama3.2",
|
||||
ContextLength: 131072,
|
||||
Details: api.ModelDetails{Format: "gguf"},
|
||||
Capabilities: []modelpkg.Capability{modelpkg.CapabilityVision, modelpkg.CapabilityThinking},
|
||||
},
|
||||
wantContext: 131072,
|
||||
},
|
||||
{
|
||||
name: "missing metadata uses fallback context",
|
||||
model: LaunchModel{Name: "llama3.2"},
|
||||
wantContext: codexFallbackContextWindow,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envContextLen != "" {
|
||||
t.Setenv("OLLAMA_CONTEXT_LENGTH", tt.envContextLen)
|
||||
} else {
|
||||
t.Setenv("OLLAMA_CONTEXT_LENGTH", "")
|
||||
}
|
||||
|
||||
entry := buildCodexModelEntry(tt.model)
|
||||
|
||||
gotContext, _ := entry["context_window"].(int)
|
||||
if gotContext != tt.wantContext {
|
||||
t.Errorf("context_window = %d, want %d", gotContext, tt.wantContext)
|
||||
}
|
||||
|
||||
if tt.name == "vision capability without reasoning advertisement" {
|
||||
modalities, _ := entry["input_modalities"].([]string)
|
||||
if !slices.Contains(modalities, "image") {
|
||||
t.Error("expected image in input_modalities")
|
||||
}
|
||||
levels, _ := entry["supported_reasoning_levels"].([]any)
|
||||
if len(levels) != 0 {
|
||||
t.Errorf("supported_reasoning_levels length = %d, want 0", len(levels))
|
||||
}
|
||||
if got, _ := entry["supports_reasoning_summaries"].(bool); got {
|
||||
t.Error("supports_reasoning_summaries = true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
if tt.name == "cloud model uses hardcoded limits" {
|
||||
truncationPolicy, _ := entry["truncation_policy"].(map[string]any)
|
||||
if mode, _ := truncationPolicy["mode"].(string); mode != "tokens" {
|
||||
t.Errorf("truncation_policy mode = %q, want %q", mode, "tokens")
|
||||
}
|
||||
}
|
||||
|
||||
requiredKeys := []string{"slug", "display_name", "shell_type"}
|
||||
for _, key := range requiredKeys {
|
||||
if _, ok := entry[key]; !ok {
|
||||
t.Errorf("missing required key %q", key)
|
||||
}
|
||||
}
|
||||
if _, ok := entry["apply_patch_tool_type"]; ok {
|
||||
t.Error("apply_patch_tool_type should be omitted so Codex CLI defaults can handle schema changes")
|
||||
}
|
||||
|
||||
if _, err := json.Marshal(entry); err != nil {
|
||||
t.Errorf("entry is not JSON serializable: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func (c *Copilot) findPath() (string, error) {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func (c *Copilot) Run(model string, args []string) error {
|
||||
func (c *Copilot) Run(model string, _ []LaunchModel, args []string) error {
|
||||
copilotPath, err := c.findPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("copilot is not installed, install from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli")
|
||||
|
||||
+9
-11
@@ -40,7 +40,7 @@ type modelEntry struct {
|
||||
|
||||
func (d *Droid) String() string { return "Droid" }
|
||||
|
||||
func (d *Droid) Run(model string, args []string) error {
|
||||
func (d *Droid) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if _, err := exec.LookPath("droid"); err != nil {
|
||||
return fmt.Errorf("droid is not installed, install from https://docs.factory.ai/cli/getting-started/quickstart")
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (d *Droid) Paths() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Droid) Edit(models []string) error {
|
||||
func (d *Droid) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -99,7 +99,7 @@ func (d *Droid) Edit(models []string) error {
|
||||
return fileutil.WriteWithBackup(settingsPath, data, "droid")
|
||||
}
|
||||
|
||||
func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []string) map[string]any {
|
||||
func updateDroidSettings(settingsMap map[string]any, settings droidSettings, models []LaunchModel) map[string]any {
|
||||
// Keep only non-Ollama models from the raw map (preserves extra fields)
|
||||
// Rebuild Ollama models
|
||||
var nonOllamaModels []any
|
||||
@@ -119,20 +119,18 @@ func updateDroidSettings(settingsMap map[string]any, settings droidSettings, mod
|
||||
var defaultModelID string
|
||||
for i, model := range models {
|
||||
maxOutput := 64000
|
||||
if isCloudModelName(model) {
|
||||
if l, ok := lookupCloudModelLimit(model); ok {
|
||||
maxOutput = l.Output
|
||||
}
|
||||
if model.MaxOutputTokens > 0 {
|
||||
maxOutput = model.MaxOutputTokens
|
||||
}
|
||||
modelID := fmt.Sprintf("custom:%s-%d", model, i)
|
||||
modelID := fmt.Sprintf("custom:%s-%d", model.Name, i)
|
||||
newModels = append(newModels, modelEntry{
|
||||
Model: model,
|
||||
DisplayName: model,
|
||||
Model: model.Name,
|
||||
DisplayName: model.Name,
|
||||
BaseURL: envconfig.Host().String() + "/v1",
|
||||
APIKey: "ollama",
|
||||
Provider: "generic-chat-completion-api",
|
||||
MaxOutputTokens: maxOutput,
|
||||
SupportsImages: false,
|
||||
SupportsImages: model.HasCapability("vision"),
|
||||
ID: modelID,
|
||||
Index: i,
|
||||
})
|
||||
|
||||
+35
-35
@@ -63,7 +63,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
|
||||
t.Run("fresh install creates models with sequential indices", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := d.Edit([]string{"model-a", "model-b"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
|
||||
t.Run("sets sessionDefaultSettings.model to first model ID", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := d.Edit([]string{"model-a", "model-b"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a", "model-b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -116,10 +116,10 @@ func TestDroidEdit(t *testing.T) {
|
||||
t.Run("re-indexes when models removed", func(t *testing.T) {
|
||||
cleanup()
|
||||
// Add three models
|
||||
d.Edit([]string{"model-a", "model-b", "model-c"})
|
||||
d.Edit(testLaunchModels("model-a", "model-b", "model-c"))
|
||||
|
||||
// Remove middle model
|
||||
d.Edit([]string{"model-a", "model-c"})
|
||||
d.Edit(testLaunchModels("model-a", "model-c"))
|
||||
|
||||
settings := readSettings()
|
||||
models := getCustomModels(settings)
|
||||
@@ -155,7 +155,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
]
|
||||
}`), 0o644)
|
||||
|
||||
d.Edit([]string{"model-a"})
|
||||
d.Edit(testLaunchModels("model-a"))
|
||||
|
||||
settings := readSettings()
|
||||
models := getCustomModels(settings)
|
||||
@@ -184,7 +184,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
"sessionDefaultSettings": {"autonomyMode": "auto-high"}
|
||||
}`), 0o644)
|
||||
|
||||
d.Edit([]string{"model-a"})
|
||||
d.Edit(testLaunchModels("model-a"))
|
||||
|
||||
settings := readSettings()
|
||||
|
||||
@@ -203,7 +203,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
|
||||
t.Run("required fields present", func(t *testing.T) {
|
||||
cleanup()
|
||||
d.Edit([]string{"test-model"})
|
||||
d.Edit(testLaunchModels("test-model"))
|
||||
|
||||
settings := readSettings()
|
||||
models := getCustomModels(settings)
|
||||
@@ -239,7 +239,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
"sessionDefaultSettings": {"reasoningEffort": "off"}
|
||||
}`), 0o644)
|
||||
|
||||
d.Edit([]string{"model-a"})
|
||||
d.Edit(testLaunchModels("model-a"))
|
||||
|
||||
settings := readSettings()
|
||||
session := settings["sessionDefaultSettings"].(map[string]any)
|
||||
@@ -256,7 +256,7 @@ func TestDroidEdit(t *testing.T) {
|
||||
"sessionDefaultSettings": {"reasoningEffort": "high"}
|
||||
}`), 0o644)
|
||||
|
||||
d.Edit([]string{"model-a"})
|
||||
d.Edit(testLaunchModels("model-a"))
|
||||
|
||||
settings := readSettings()
|
||||
session := settings["sessionDefaultSettings"].(map[string]any)
|
||||
@@ -281,7 +281,7 @@ func TestDroidEdit_CorruptedJSON(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(`{corrupted json content`), 0o644)
|
||||
|
||||
// Corrupted JSON should return an error so user knows something is wrong
|
||||
err := d.Edit([]string{"model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a"))
|
||||
if err == nil {
|
||||
t.Fatal("expected error for corrupted JSON, got nil")
|
||||
}
|
||||
@@ -306,7 +306,7 @@ func TestDroidEdit_WrongTypeCustomModels(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(`{"customModels": "not an array"}`), 0o644)
|
||||
|
||||
// Should not panic - wrong type should be handled gracefully
|
||||
err := d.Edit([]string{"model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit failed with wrong type customModels: %v", err)
|
||||
}
|
||||
@@ -338,7 +338,7 @@ func TestDroidEdit_EmptyModels(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(originalContent), 0o644)
|
||||
|
||||
// Empty models should be no-op
|
||||
err := d.Edit([]string{})
|
||||
err := d.Edit(testLaunchModels())
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with empty models failed: %v", err)
|
||||
}
|
||||
@@ -359,7 +359,7 @@ func TestDroidEdit_DuplicateModels(t *testing.T) {
|
||||
settingsPath := filepath.Join(settingsDir, "settings.json")
|
||||
|
||||
// Add same model twice
|
||||
err := d.Edit([]string{"model-a", "model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a", "model-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with duplicates failed: %v", err)
|
||||
}
|
||||
@@ -388,7 +388,7 @@ func TestDroidEdit_MalformedModelEntry(t *testing.T) {
|
||||
// Model entry is a string instead of a map
|
||||
os.WriteFile(settingsPath, []byte(`{"customModels": ["not a map", 123]}`), 0o644)
|
||||
|
||||
err := d.Edit([]string{"model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with malformed entries failed: %v", err)
|
||||
}
|
||||
@@ -415,7 +415,7 @@ func TestDroidEdit_WrongTypeSessionSettings(t *testing.T) {
|
||||
// sessionDefaultSettings is a string instead of map
|
||||
os.WriteFile(settingsPath, []byte(`{"sessionDefaultSettings": "not a map"}`), 0o644)
|
||||
|
||||
err := d.Edit([]string{"model-a"})
|
||||
err := d.Edit(testLaunchModels("model-a"))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with wrong type sessionDefaultSettings failed: %v", err)
|
||||
}
|
||||
@@ -490,7 +490,7 @@ func TestDroidEdit_RoundTrip(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644)
|
||||
|
||||
// Edit with new models
|
||||
if err := d.Edit([]string{"llama3", "mistral"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("llama3", "mistral")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -615,7 +615,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -660,7 +660,7 @@ func TestDroidEdit_PreservesUnknownFields(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"llama3"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("llama3")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -715,10 +715,10 @@ func TestDroidEdit_Idempotent(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(testDroidSettingsFixture), 0o644)
|
||||
|
||||
// Edit twice with same models
|
||||
d.Edit([]string{"llama3", "mistral"})
|
||||
d.Edit(testLaunchModels("llama3", "mistral"))
|
||||
firstData, _ := os.ReadFile(settingsPath)
|
||||
|
||||
d.Edit([]string{"llama3", "mistral"})
|
||||
d.Edit(testLaunchModels("llama3", "mistral"))
|
||||
secondData, _ := os.ReadFile(settingsPath)
|
||||
|
||||
// Results should be identical
|
||||
@@ -744,7 +744,7 @@ func TestDroidEdit_MultipleConsecutiveEdits(t *testing.T) {
|
||||
if i%2 == 0 {
|
||||
models = []string{"model-x", "model-y", "model-z"}
|
||||
}
|
||||
if err := d.Edit(models); err != nil {
|
||||
if err := d.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("edit %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
@@ -803,7 +803,7 @@ func TestDroidEdit_UnicodeAndSpecialCharacters(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -845,7 +845,7 @@ func TestDroidEdit_LargeNumbers(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -889,7 +889,7 @@ func TestDroidEdit_EmptyAndNullValues(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -943,7 +943,7 @@ func TestDroidEdit_DeeplyNestedStructures(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -988,7 +988,7 @@ func TestDroidEdit_ModelNamesWithSpecialCharacters(t *testing.T) {
|
||||
"model_with_underscores",
|
||||
}
|
||||
|
||||
if err := d.Edit(specialModels); err != nil {
|
||||
if err := d.Edit(launchModelsFromNames(specialModels)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1025,7 +1025,7 @@ func TestDroidEdit_MissingCustomModelsKey(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
settings = updateDroidSettings(settings, settingsStruct, []string{"model-a"})
|
||||
settings = updateDroidSettings(settings, settingsStruct, testLaunchModels("model-a"))
|
||||
|
||||
// Original fields preserved
|
||||
if settings["diffMode"] != "github" {
|
||||
@@ -1062,7 +1062,7 @@ func TestDroidEdit_NullCustomModels(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1090,7 +1090,7 @@ func TestDroidEdit_MinifiedJSON(t *testing.T) {
|
||||
original := `{"diffMode":"github","enableHooks":true,"hooks":{"imported":["cmd1","cmd2"]},"customModels":[],"sessionDefaultSettings":{}}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1120,7 +1120,7 @@ func TestDroidEdit_CreatesDirectoryIfMissing(t *testing.T) {
|
||||
t.Fatal("directory should not exist before test")
|
||||
}
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1157,7 +1157,7 @@ func TestDroidEdit_PreservesFileAfterError(t *testing.T) {
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
// Empty models list is a no-op, should not modify file
|
||||
d.Edit([]string{})
|
||||
d.Edit(testLaunchModels())
|
||||
|
||||
data, _ := os.ReadFile(settingsPath)
|
||||
if string(data) != original {
|
||||
@@ -1181,7 +1181,7 @@ func TestDroidEdit_BackupCreated(t *testing.T) {
|
||||
original := fmt.Sprintf(`{"diffMode": "%s", "customModels": [], "sessionDefaultSettings": {}}`, uniqueMarker)
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1231,7 +1231,7 @@ func TestDroidEdit_LargeNumberOfModels(t *testing.T) {
|
||||
models = append(models, fmt.Sprintf("model-%d", i))
|
||||
}
|
||||
|
||||
if err := d.Edit(models); err != nil {
|
||||
if err := d.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1261,7 +1261,7 @@ func TestDroidEdit_LocalModelDefaultMaxOutput(t *testing.T) {
|
||||
settingsDir := filepath.Join(tmpDir, ".factory")
|
||||
settingsPath := filepath.Join(settingsDir, "settings.json")
|
||||
|
||||
if err := d.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1312,7 +1312,7 @@ func TestDroidEdit_ArraysWithMixedTypes(t *testing.T) {
|
||||
}`
|
||||
os.WriteFile(settingsPath, []byte(original), 0o644)
|
||||
|
||||
if err := d.Edit([]string{"model-a"}); err != nil {
|
||||
if err := d.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ type Hermes struct{}
|
||||
|
||||
func (h *Hermes) String() string { return "Hermes Agent" }
|
||||
|
||||
func (h *Hermes) Run(_ string, args []string) error {
|
||||
func (h *Hermes) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
// Hermes reads its primary model from config.yaml. launch configures that
|
||||
// default model ahead of time so we can keep runtime invocation simple and
|
||||
// still let Hermes discover additional models later via its own UX.
|
||||
|
||||
@@ -552,7 +552,7 @@ func TestHermesRunPassthroughArgs(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", []string{"--continue"}); err != nil {
|
||||
if err := h.Run("", nil, []string{"--continue"}); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -603,7 +603,7 @@ fi
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -655,10 +655,10 @@ func TestHermesRun_SetUpLaterRepromptsOnLaterLaunches(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("first Run returned error: %v", err)
|
||||
}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("second Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -713,7 +713,7 @@ func TestHermesRun_SkipsMessagingPromptWhenConfigured(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -753,7 +753,7 @@ func TestHermesRun_SkipsMessagingPromptWithYesPolicy(t *testing.T) {
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
if err := h.Run("", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -798,7 +798,7 @@ fi
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
err := h.Run("", nil)
|
||||
err := h.Run("", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected messaging setup failure")
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ type stubEditorRunner struct {
|
||||
editErr error
|
||||
}
|
||||
|
||||
func (s *stubEditorRunner) Run(model string, args []string) error {
|
||||
func (s *stubEditorRunner) Run(model string, _ []LaunchModel, args []string) error {
|
||||
s.ranModel = model
|
||||
return nil
|
||||
}
|
||||
@@ -34,11 +34,11 @@ func (s *stubEditorRunner) String() string { return "StubEditor" }
|
||||
|
||||
func (s *stubEditorRunner) Paths() []string { return nil }
|
||||
|
||||
func (s *stubEditorRunner) Edit(models []string) error {
|
||||
func (s *stubEditorRunner) Edit(models []LaunchModel) error {
|
||||
if s.editErr != nil {
|
||||
return s.editErr
|
||||
}
|
||||
cloned := append([]string(nil), models...)
|
||||
cloned := launchModelNames(models)
|
||||
s.edited = append(s.edited, cloned)
|
||||
return nil
|
||||
}
|
||||
@@ -58,6 +58,9 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
{"claude desktop", "claude-desktop", true, "Claude Desktop"},
|
||||
{"claude desktop alias", "claude-app", true, "Claude Desktop"},
|
||||
{"codex", "codex", true, "Codex"},
|
||||
{"codex app", "codex-app", true, "Codex App"},
|
||||
{"codex app desktop alias", "codex-desktop", true, "Codex App"},
|
||||
{"codex app gui alias", "codex-gui", true, "Codex App"},
|
||||
{"kimi", "kimi", true, "Kimi Code CLI"},
|
||||
{"droid", "droid", true, "Droid"},
|
||||
{"opencode", "opencode", true, "OpenCode"},
|
||||
@@ -80,7 +83,7 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIntegrationRegistry(t *testing.T) {
|
||||
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "kimi", "droid", "opencode", "hermes", "pool"}
|
||||
expectedIntegrations := []string{"claude", "claude-desktop", "codex", "codex-app", "kimi", "droid", "opencode", "hermes", "pool"}
|
||||
for _, name := range expectedIntegrations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r, ok := integrations[name]
|
||||
@@ -203,7 +206,7 @@ func TestAllIntegrations_HaveRequiredMethods(t *testing.T) {
|
||||
if displayName == "" {
|
||||
t.Error("String() should not return empty")
|
||||
}
|
||||
var _ func(string, []string) error = r.Run
|
||||
var _ func(string, []LaunchModel, []string) error = r.Run
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -478,11 +481,11 @@ func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
|
||||
func TestBuildModelList_PreservesRecommendationRequiredPlanForExistingCloudModel(t *testing.T) {
|
||||
recommendations := []ModelItem{
|
||||
{
|
||||
Name: "glm-5:cloud",
|
||||
Description: "Reasoning and code generation",
|
||||
Recommended: true,
|
||||
RequiredPlan: "pro",
|
||||
ContextLength: 202_752,
|
||||
Name: "glm-5:cloud",
|
||||
Description: "Reasoning and code generation",
|
||||
Recommended: true,
|
||||
RequiredPlan: "pro",
|
||||
Details: api.ModelDetails{ContextLength: 202_752},
|
||||
},
|
||||
}
|
||||
existing := []modelInfo{{Name: "glm-5:cloud", Remote: true}}
|
||||
@@ -863,7 +866,7 @@ func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
editor := &stubEditorRunner{editErr: errors.New("boom")}
|
||||
err := prepareEditorIntegration("droid", editor, []string{"new-model"})
|
||||
err := prepareEditorIntegration("droid", editor, testLaunchModels("new-model"))
|
||||
if err == nil || !strings.Contains(err.Error(), "setup failed") {
|
||||
t.Fatalf("expected setup failure, got %v", err)
|
||||
}
|
||||
@@ -1737,6 +1740,11 @@ func TestIntegration_InstallHint(t *testing.T) {
|
||||
input: "codex",
|
||||
wantURL: "https://developers.openai.com/codex/cli/",
|
||||
},
|
||||
{
|
||||
name: "codex app has hint",
|
||||
input: "codex-app",
|
||||
wantURL: "https://developers.openai.com/codex/quickstart",
|
||||
},
|
||||
{
|
||||
name: "openclaw has hint",
|
||||
input: "openclaw",
|
||||
@@ -1813,11 +1821,38 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
}
|
||||
want = filtered
|
||||
}
|
||||
if codexAppSupported() != nil {
|
||||
filtered := make([]string, 0, len(want))
|
||||
for _, name := range want {
|
||||
if name != "codex-app" {
|
||||
filtered = append(filtered, name)
|
||||
}
|
||||
}
|
||||
want = filtered
|
||||
}
|
||||
|
||||
if diff := compareStrings(got, want); diff != "" {
|
||||
t.Fatalf("launcher integration order mismatch: %s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prioritizes primary launcher integrations", func(t *testing.T) {
|
||||
got := make([]string, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
got = append(got, info.Name)
|
||||
}
|
||||
wantPrefix := []string{"claude", "codex-app", "hermes", "openclaw"}
|
||||
if codexAppSupported() != nil {
|
||||
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode"}
|
||||
}
|
||||
if len(got) < len(wantPrefix) {
|
||||
t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got)
|
||||
}
|
||||
if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" {
|
||||
t.Fatalf("unexpected primary launcher order: %s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all fields populated", func(t *testing.T) {
|
||||
for _, info := range infos {
|
||||
if info.Name == "" {
|
||||
@@ -1831,6 +1866,9 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
|
||||
t.Run("includes known integrations", func(t *testing.T) {
|
||||
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
|
||||
if codexAppSupported() == nil {
|
||||
known["codex-app"] = false
|
||||
}
|
||||
if poolsideGOOS != "windows" {
|
||||
known["pool"] = false
|
||||
}
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ func (k *Kimi) args(config string, extra []string) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func (k *Kimi) Run(model string, args []string) error {
|
||||
func (k *Kimi) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ func TestKimiRun_RejectsConflictingArgsBeforeInstall(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
||||
|
||||
err := k.Run("llama3.2", []string{"--model", "other"})
|
||||
err := k.Run("llama3.2", nil, []string{"--model", "other"})
|
||||
if err == nil || !strings.Contains(err.Error(), "--model") {
|
||||
t.Fatalf("expected conflict error mentioning --model, got %v", err)
|
||||
}
|
||||
@@ -337,7 +337,7 @@ exit 0
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
k := &Kimi{}
|
||||
if err := k.Run("llama3.2", []string{"--quiet", "--print"}); err != nil {
|
||||
if err := k.Run("llama3.2", nil, []string{"--quiet", "--print"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
|
||||
+74
-66
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
@@ -137,16 +138,17 @@ var isInteractiveSession = func() bool {
|
||||
return term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd()))
|
||||
}
|
||||
|
||||
// Runner executes a model with an integration.
|
||||
// Runner executes an integration with the selected model and its resolved
|
||||
// launch metadata. models is ordered with the primary model first.
|
||||
type Runner interface {
|
||||
Run(model string, args []string) error
|
||||
Run(model string, models []LaunchModel, args []string) error
|
||||
String() string
|
||||
}
|
||||
|
||||
// Editor can edit config files for integrations that support model configuration.
|
||||
type Editor interface {
|
||||
Paths() []string
|
||||
Edit(models []string) error
|
||||
Edit(models []LaunchModel) error
|
||||
Models() []string
|
||||
}
|
||||
|
||||
@@ -165,7 +167,7 @@ type ManagedSingleModel interface {
|
||||
// ManagedModelListConfigurer lets managed single-model integrations receive
|
||||
// the launcher's model list while still preserving one primary selected model.
|
||||
type ManagedModelListConfigurer interface {
|
||||
ConfigureWithModels(primary string, models []string) error
|
||||
ConfigureWithModels(primary string, models []LaunchModel) error
|
||||
}
|
||||
|
||||
// ManagedAutodiscoveryIntegration is for managed integrations that do not need
|
||||
@@ -239,24 +241,18 @@ type SupportedIntegration interface {
|
||||
Supported() error
|
||||
}
|
||||
|
||||
type modelInfo struct {
|
||||
Name string
|
||||
Remote bool
|
||||
ToolCapable bool
|
||||
}
|
||||
|
||||
// ModelInfo re-exports launcher model inventory details for callers.
|
||||
type ModelInfo = modelInfo
|
||||
|
||||
// ModelItem represents model metadata before selector-only UI state is derived.
|
||||
type ModelItem struct {
|
||||
Name string
|
||||
Description string
|
||||
Recommended bool
|
||||
VRAMBytes int64
|
||||
ContextLength int
|
||||
MaxOutputTokens int
|
||||
RequiredPlan string
|
||||
ToolCapable bool
|
||||
Capabilities []modelpkg.Capability
|
||||
Size int64
|
||||
Details api.ModelDetails
|
||||
}
|
||||
|
||||
// SelectionItem represents a model row after launch has derived selector-only UI state.
|
||||
@@ -285,22 +281,25 @@ Flags and extra arguments require an integration name.
|
||||
|
||||
Supported integrations:
|
||||
claude Claude Code
|
||||
cline Cline
|
||||
codex-app Codex App (aliases: codex-desktop, codex-gui)
|
||||
hermes Hermes Agent
|
||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||
opencode OpenCode
|
||||
codex Codex
|
||||
copilot Copilot CLI (aliases: copilot-cli)
|
||||
droid Droid
|
||||
hermes Hermes Agent
|
||||
kimi Kimi Code CLI
|
||||
opencode OpenCode
|
||||
openclaw OpenClaw (aliases: clawdbot, moltbot)
|
||||
pi Pi
|
||||
pool Pool
|
||||
cline Cline
|
||||
vscode VS Code (aliases: code)
|
||||
|
||||
Examples:
|
||||
ollama launch
|
||||
ollama launch claude
|
||||
ollama launch claude --model <model>
|
||||
ollama launch codex-app
|
||||
ollama launch codex-app --restore
|
||||
ollama launch hermes
|
||||
ollama launch droid --config (does not auto-launch)
|
||||
ollama launch codex -- -p myprofile (pass extra args to integration)
|
||||
@@ -406,8 +405,7 @@ func launchCommandIsClaudeDesktop(name string) bool {
|
||||
|
||||
type launcherClient struct {
|
||||
apiClient *api.Client
|
||||
modelInventory []ModelInfo
|
||||
inventoryLoaded bool
|
||||
inventory *modelInventory
|
||||
recommendationsLoaded bool
|
||||
recommendationItems []ModelItem
|
||||
accountState *AccountState
|
||||
@@ -424,10 +422,18 @@ func newLauncherClient(policy LaunchPolicy) (*launcherClient, error) {
|
||||
|
||||
return &launcherClient{
|
||||
apiClient: apiClient,
|
||||
inventory: newModelInventory(apiClient),
|
||||
policy: policy,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) modelInventory() *modelInventory {
|
||||
if c.inventory == nil {
|
||||
c.inventory = newModelInventory(c.apiClient)
|
||||
}
|
||||
return c.inventory
|
||||
}
|
||||
|
||||
// BuildLauncherState returns the launch-owned root launcher menu snapshot.
|
||||
func BuildLauncherState(ctx context.Context) (*LauncherState, error) {
|
||||
launchClient, err := newLauncherClient(defaultLaunchPolicy(isInteractiveSession(), false))
|
||||
@@ -549,7 +555,7 @@ func prepareIntegrationLaunch(name string, policy LaunchPolicy) (*launcherClient
|
||||
}
|
||||
|
||||
func (c *launcherClient) buildLauncherState(ctx context.Context) (*LauncherState, error) {
|
||||
_ = c.loadModelInventoryOnce(ctx)
|
||||
_, _ = c.modelInventory().Load(ctx)
|
||||
|
||||
state := &LauncherState{
|
||||
LastSelection: config.LastSelection(),
|
||||
@@ -723,7 +729,7 @@ func (c *launcherClient) launchSingleIntegration(ctx context.Context, name strin
|
||||
}
|
||||
}
|
||||
|
||||
return launchAfterConfiguration(name, runner, target, req)
|
||||
return launchAfterConfiguration(name, runner, target, c.resolveRunModels(ctx, []string{target}), req)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchEditorIntegration(ctx context.Context, name string, runner Runner, editor Editor, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -745,13 +751,17 @@ func (c *launcherClient) launchEditorIntegration(ctx context.Context, name strin
|
||||
return nil
|
||||
}
|
||||
|
||||
var launchModels []LaunchModel
|
||||
if (needsConfigure || req.ModelOverride != "") && !savedMatchesModels(saved, models) {
|
||||
if err := prepareEditorIntegration(name, editor, models); err != nil {
|
||||
launchModels = c.modelInventory().Resolve(ctx, models)
|
||||
if err := prepareEditorIntegration(name, editor, launchModels); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
launchModels = c.resolveRunModels(ctx, models)
|
||||
}
|
||||
|
||||
return launchAfterConfiguration(name, runner, models[0], req)
|
||||
return launchAfterConfiguration(name, runner, models[0], launchModels, req)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, name string, runner Runner, managed ManagedSingleModel, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -769,12 +779,18 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
||||
return nil
|
||||
}
|
||||
|
||||
if needsConfigure || req.ModelOverride != "" || (current != "" && target != current) || !savedMatchesModels(saved, []string{target}) {
|
||||
// current is the live managed app config; target may come from saved launch
|
||||
// state. Rewrite when the live config is missing or has drifted so the app
|
||||
// config converges with the model which launch is about to use.
|
||||
liveConfigMissing := current == ""
|
||||
liveConfigDrifted := current != "" && target != current
|
||||
configured := false
|
||||
if needsConfigure || req.ModelOverride != "" || liveConfigMissing || liveConfigDrifted || !savedMatchesModels(saved, []string{target}) {
|
||||
configureModels, err := c.managedSingleConfigureModels(ctx, managed, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := prepareManagedSingleIntegration(name, managed, target, configureModels); err != nil {
|
||||
if err := prepareManagedSingleIntegration(name, managed, target, c.modelInventory().Resolve(ctx, configureModels)); err != nil {
|
||||
return err
|
||||
}
|
||||
if refresher, ok := managed.(ManagedRuntimeRefresher); ok {
|
||||
@@ -782,6 +798,7 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
||||
return err
|
||||
}
|
||||
}
|
||||
configured = true
|
||||
}
|
||||
|
||||
if !managedIntegrationOnboarded(saved, managed) {
|
||||
@@ -793,11 +810,17 @@ func (c *launcherClient) launchManagedSingleIntegration(ctx context.Context, nam
|
||||
}
|
||||
}
|
||||
|
||||
if configured {
|
||||
if !printConfigurationSuccess(managed) {
|
||||
printRestoreHint(managed)
|
||||
}
|
||||
}
|
||||
|
||||
if req.ConfigureOnly {
|
||||
return nil
|
||||
}
|
||||
|
||||
return runIntegration(runner, target, req.ExtraArgs)
|
||||
return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
|
||||
}
|
||||
|
||||
func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Context, name string, runner Runner, autodiscovery ManagedAutodiscoveryIntegration, saved *config.IntegrationConfig, req IntegrationLaunchRequest) error {
|
||||
@@ -840,7 +863,7 @@ func (c *launcherClient) launchManagedAutodiscoveryIntegration(ctx context.Conte
|
||||
return nil
|
||||
}
|
||||
|
||||
return runIntegration(runner, target, req.ExtraArgs)
|
||||
return runIntegration(runner, target, c.resolveRunModels(ctx, []string{target}), req.ExtraArgs)
|
||||
}
|
||||
|
||||
func (c *launcherClient) managedAutodiscoveryUsable(ctx context.Context, autodiscovery ManagedAutodiscoveryIntegration) bool {
|
||||
@@ -941,7 +964,7 @@ func (c *launcherClient) resolveSingleIntegrationTarget(ctx context.Context, run
|
||||
}
|
||||
}
|
||||
|
||||
if needsConfigure {
|
||||
if needsConfigure && req.ModelOverride == "" {
|
||||
selected, err := c.selectSingleModelWithSelectorReady(ctx, fmt.Sprintf("Select model for %s:", runner), target, DefaultSingleSelector, !skipReadiness)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
@@ -1092,13 +1115,14 @@ func runMultiSelector(title string, items []SelectionItem, preChecked []string,
|
||||
}
|
||||
|
||||
func (c *launcherClient) loadSelectableModels(ctx context.Context, preChecked []string, current, emptyMessage string) ([]ModelItem, []string, error) {
|
||||
if err := c.loadModelInventoryOnce(ctx); err != nil {
|
||||
inventory, err := c.modelInventory().Load(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
recommendations := c.recommendations(ctx)
|
||||
|
||||
cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
|
||||
items, orderedChecked, _, _ := buildModelListWithRecommendations(c.modelInventory, recommendations, preChecked, current)
|
||||
items, orderedChecked, _, _ := buildModelListWithRecommendations(inventory, recommendations, preChecked, current)
|
||||
if cloudDisabled {
|
||||
items = filterCloudItems(items)
|
||||
orderedChecked = c.filterDisabledCloudModels(ctx, orderedChecked)
|
||||
@@ -1163,9 +1187,11 @@ func (c *launcherClient) requestRecommendations(ctx context.Context) ([]ModelIte
|
||||
Description: description,
|
||||
Recommended: true,
|
||||
VRAMBytes: rec.VRAMBytes,
|
||||
ContextLength: rec.ContextLength,
|
||||
MaxOutputTokens: rec.MaxOutputTokens,
|
||||
RequiredPlan: strings.TrimSpace(rec.RequiredPlan),
|
||||
Details: api.ModelDetails{
|
||||
ContextLength: rec.ContextLength,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1286,10 +1312,11 @@ func (c *launcherClient) filterDisabledCloudModels(ctx context.Context, models [
|
||||
}
|
||||
|
||||
func (c *launcherClient) savedModelUsable(ctx context.Context, name string) (bool, error) {
|
||||
if err := c.loadModelInventoryOnce(ctx); err != nil {
|
||||
inventory, err := c.modelInventory().Load(ctx)
|
||||
if err != nil {
|
||||
return c.showBasedModelUsable(ctx, name)
|
||||
}
|
||||
return c.singleModelUsable(ctx, name), nil
|
||||
return c.singleModelUsable(ctx, name, inventory), nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) showBasedModelUsable(ctx context.Context, name string) (bool, error) {
|
||||
@@ -1315,7 +1342,7 @@ func (c *launcherClient) showBasedModelUsable(ctx context.Context, name string)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) singleModelUsable(ctx context.Context, name string) bool {
|
||||
func (c *launcherClient) singleModelUsable(ctx context.Context, name string, inventory []LaunchModel) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
@@ -1323,11 +1350,11 @@ func (c *launcherClient) singleModelUsable(ctx context.Context, name string) boo
|
||||
cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
|
||||
return !cloudDisabled
|
||||
}
|
||||
return c.hasLocalModel(name)
|
||||
return hasLocalModel(inventory, name)
|
||||
}
|
||||
|
||||
func (c *launcherClient) hasLocalModel(name string) bool {
|
||||
for _, model := range c.modelInventory {
|
||||
func hasLocalModel(inventory []LaunchModel, name string) bool {
|
||||
for _, model := range inventory {
|
||||
if model.Remote {
|
||||
continue
|
||||
}
|
||||
@@ -1338,37 +1365,18 @@ func (c *launcherClient) hasLocalModel(name string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *launcherClient) loadModelInventoryOnce(ctx context.Context) error {
|
||||
if c.inventoryLoaded {
|
||||
return nil
|
||||
}
|
||||
|
||||
resp, err := c.apiClient.List(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
c.modelInventory = c.modelInventory[:0]
|
||||
for _, model := range resp.Models {
|
||||
c.modelInventory = append(c.modelInventory, ModelInfo{
|
||||
Name: model.Name,
|
||||
Remote: model.RemoteModel != "",
|
||||
})
|
||||
}
|
||||
|
||||
cloudDisabled, _ := cloudStatusDisabled(ctx, c.apiClient)
|
||||
if cloudDisabled {
|
||||
c.modelInventory = filterCloudModels(c.modelInventory)
|
||||
}
|
||||
c.inventoryLoaded = true
|
||||
return nil
|
||||
func (c *launcherClient) resolveRunModels(ctx context.Context, models []string) []LaunchModel {
|
||||
return c.modelInventory().Resolve(ctx, models)
|
||||
}
|
||||
|
||||
func runIntegration(runner Runner, modelName string, args []string) error {
|
||||
return runner.Run(modelName, args)
|
||||
func runIntegration(runner Runner, modelName string, models []LaunchModel, args []string) error {
|
||||
if len(models) == 0 && modelName != "" {
|
||||
models = launchModelsFromNames([]string{modelName})
|
||||
}
|
||||
return runner.Run(modelName, models, args)
|
||||
}
|
||||
|
||||
func launchAfterConfiguration(name string, runner Runner, model string, req IntegrationLaunchRequest) error {
|
||||
func launchAfterConfiguration(name string, runner Runner, model string, models []LaunchModel, req IntegrationLaunchRequest) error {
|
||||
if req.ConfigureOnly {
|
||||
launch, err := ConfirmPrompt(fmt.Sprintf("Launch %s now?", runner))
|
||||
if err != nil {
|
||||
@@ -1381,7 +1389,7 @@ func launchAfterConfiguration(name string, runner Runner, model string, req Inte
|
||||
if err := EnsureIntegrationInstalled(name, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
return runIntegration(runner, model, req.ExtraArgs)
|
||||
return runIntegration(runner, model, models, req.ExtraArgs)
|
||||
}
|
||||
|
||||
func loadStoredIntegrationConfig(name string) (*config.IntegrationConfig, error) {
|
||||
|
||||
+235
-17
@@ -11,6 +11,7 @@ import (
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -24,7 +25,7 @@ type launcherEditorRunner struct {
|
||||
ranModel string
|
||||
}
|
||||
|
||||
func (r *launcherEditorRunner) Run(model string, args []string) error {
|
||||
func (r *launcherEditorRunner) Run(model string, _ []LaunchModel, args []string) error {
|
||||
r.ranModel = model
|
||||
return nil
|
||||
}
|
||||
@@ -33,8 +34,8 @@ func (r *launcherEditorRunner) String() string { return "LauncherEditor" }
|
||||
|
||||
func (r *launcherEditorRunner) Paths() []string { return r.paths }
|
||||
|
||||
func (r *launcherEditorRunner) Edit(models []string) error {
|
||||
r.edited = append(r.edited, append([]string(nil), models...))
|
||||
func (r *launcherEditorRunner) Edit(models []LaunchModel) error {
|
||||
r.edited = append(r.edited, launchModelNames(models))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ type launcherSingleRunner struct {
|
||||
ranModel string
|
||||
}
|
||||
|
||||
func (r *launcherSingleRunner) Run(model string, args []string) error {
|
||||
func (r *launcherSingleRunner) Run(model string, _ []LaunchModel, args []string) error {
|
||||
r.ranModel = model
|
||||
return nil
|
||||
}
|
||||
@@ -68,18 +69,21 @@ func (r *launcherRestorableRunner) RestoreSuccessMessage() string {
|
||||
}
|
||||
|
||||
type launcherManagedRunner struct {
|
||||
paths []string
|
||||
currentModel string
|
||||
configured []string
|
||||
ranModel string
|
||||
onboarded bool
|
||||
onboardCalls int
|
||||
onboardingComplete bool
|
||||
refreshCalls int
|
||||
refreshErr error
|
||||
paths []string
|
||||
currentModel string
|
||||
configured []string
|
||||
ranModel string
|
||||
onboarded bool
|
||||
onboardCalls int
|
||||
onboardingComplete bool
|
||||
refreshCalls int
|
||||
refreshErr error
|
||||
restoreHint string
|
||||
configSuccessMessage string
|
||||
skipModelReadiness bool
|
||||
}
|
||||
|
||||
func (r *launcherManagedRunner) Run(model string, args []string) error {
|
||||
func (r *launcherManagedRunner) Run(model string, _ []LaunchModel, args []string) error {
|
||||
r.ranModel = model
|
||||
return nil
|
||||
}
|
||||
@@ -110,6 +114,14 @@ func (r *launcherManagedRunner) RefreshRuntimeAfterConfigure() error {
|
||||
return r.refreshErr
|
||||
}
|
||||
|
||||
func (r *launcherManagedRunner) RestoreHint() string { return r.restoreHint }
|
||||
|
||||
func (r *launcherManagedRunner) ConfigurationSuccessMessage() string {
|
||||
return r.configSuccessMessage
|
||||
}
|
||||
|
||||
func (r *launcherManagedRunner) SkipModelReadiness() bool { return r.skipModelReadiness }
|
||||
|
||||
type launcherHeadlessManagedRunner struct {
|
||||
launcherManagedRunner
|
||||
}
|
||||
@@ -121,8 +133,8 @@ type launcherManagedListRunner struct {
|
||||
configuredModelLists [][]string
|
||||
}
|
||||
|
||||
func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []string) error {
|
||||
r.configuredModelLists = append(r.configuredModelLists, append([]string(nil), models...))
|
||||
func (r *launcherManagedListRunner) ConfigureWithModels(primary string, models []LaunchModel) error {
|
||||
r.configuredModelLists = append(r.configuredModelLists, launchModelNames(models))
|
||||
return r.Configure(primary)
|
||||
}
|
||||
|
||||
@@ -480,6 +492,116 @@ func TestLaunchIntegration_ManagedSingleIntegrationConfigOnlySkipsFinalRun(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationPrintsConfigurationSuccessAfterConfigure(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, true)
|
||||
withLauncherHooks(t)
|
||||
|
||||
runner := &launcherManagedRunner{
|
||||
configSuccessMessage: "configured successfully\nrestore via success message",
|
||||
restoreHint: "run restore command",
|
||||
skipModelReadiness: true,
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||
Name: "stubmanaged",
|
||||
ModelOverride: "gemma4",
|
||||
ForceConfigure: true,
|
||||
ConfigureOnly: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
|
||||
t.Fatalf("configured models mismatch: %s", diff)
|
||||
}
|
||||
if !strings.Contains(stderr, "configured successfully") {
|
||||
t.Fatalf("expected configuration success in stderr, got %q", stderr)
|
||||
}
|
||||
if !strings.Contains(stderr, "restore via success message") {
|
||||
t.Fatalf("expected restore guidance in configuration success, got %q", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "run restore command") {
|
||||
t.Fatalf("restore hint should not print separately after configure, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationDoesNotPrintRestoreHintWhenUnchanged(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, true)
|
||||
withLauncherHooks(t)
|
||||
|
||||
runner := &launcherManagedRunner{
|
||||
currentModel: "gemma4",
|
||||
onboardingComplete: true,
|
||||
configSuccessMessage: "configured successfully",
|
||||
restoreHint: "run restore command",
|
||||
skipModelReadiness: true,
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
|
||||
t.Fatalf("failed to save managed integration config: %v", err)
|
||||
}
|
||||
if err := config.MarkIntegrationOnboarded("stubmanaged"); err != nil {
|
||||
t.Fatalf("failed to mark integration onboarded: %v", err)
|
||||
}
|
||||
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if len(runner.configured) != 0 {
|
||||
t.Fatalf("expected Configure to be skipped when saved matches, got %v", runner.configured)
|
||||
}
|
||||
if strings.Contains(stderr, "configured successfully") {
|
||||
t.Fatalf("configuration success should not print when config is unchanged, got %q", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "run restore command") {
|
||||
t.Fatalf("restore hint should not print when config is unchanged, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationForceConfigureUsesModelOverride(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, true)
|
||||
withLauncherHooks(t)
|
||||
|
||||
runner := &launcherManagedRunner{
|
||||
paths: nil,
|
||||
skipModelReadiness: true,
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
|
||||
return "", fmt.Errorf("selector should not run with an explicit model override")
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{
|
||||
Name: "stubmanaged",
|
||||
ModelOverride: "gemma4",
|
||||
ForceConfigure: true,
|
||||
ConfigureOnly: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
|
||||
t.Fatalf("configured models mismatch: %s", diff)
|
||||
}
|
||||
if runner.ranModel != "" {
|
||||
t.Fatalf("expected configure-only flow to skip final launch, got %q", runner.ranModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -505,7 +627,9 @@ func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(
|
||||
t.Fatalf("failed to save managed integration config: %v", err)
|
||||
}
|
||||
|
||||
runner := &launcherManagedRunner{}
|
||||
runner := &launcherManagedRunner{
|
||||
currentModel: "gemma4",
|
||||
}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
|
||||
@@ -532,6 +656,53 @@ func TestLaunchIntegration_ManagedSingleIntegrationSkipsRewriteWhenSavedMatches(
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedMatchesButLiveConfigMissing(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, true)
|
||||
withLauncherHooks(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
if err := config.SaveIntegration("stubmanaged", []string{"gemma4"}); err != nil {
|
||||
t.Fatalf("failed to save managed integration config: %v", err)
|
||||
}
|
||||
|
||||
runner := &launcherManagedRunner{}
|
||||
withIntegrationOverride(t, "stubmanaged", runner)
|
||||
|
||||
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
|
||||
t.Fatal("selector should not be called when saved model is usable")
|
||||
return "", nil
|
||||
}
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "stubmanaged"}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
|
||||
if diff := compareStrings(runner.configured, []string{"gemma4"}); diff != "" {
|
||||
t.Fatalf("expected Configure to rewrite missing live config: %s", diff)
|
||||
}
|
||||
if runner.refreshCalls != 1 {
|
||||
t.Fatalf("expected runtime refresh once after rewrite, got %d", runner.refreshCalls)
|
||||
}
|
||||
if runner.ranModel != "gemma4" {
|
||||
t.Fatalf("expected launch to run saved model, got %q", runner.ranModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ManagedSingleIntegrationRewritesWhenSavedDiffers(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -1292,6 +1463,53 @@ func TestBuildLauncherState_ToleratesInventoryFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildLauncherState_UsesTagsInventoryWithoutShow(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
|
||||
if err := config.SetLastModel("llama3.2"); err != nil {
|
||||
t.Fatalf("failed to seed last model: %v", err)
|
||||
}
|
||||
if err := config.SaveIntegration("codex", []string{"qwen3:8b"}); err != nil {
|
||||
t.Fatalf("failed to seed codex config: %v", err)
|
||||
}
|
||||
|
||||
var showCalls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[`+
|
||||
`{"name":"llama3.2","capabilities":["completion","tools"],"context_length":131072,"size":3200000000},`+
|
||||
`{"name":"qwen3:8b","capabilities":["completion","tools"],"context_length":65536,"size":4500000000}`+
|
||||
`]}`)
|
||||
case "/api/show":
|
||||
showCalls.Add(1)
|
||||
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
state, err := BuildLauncherState(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("BuildLauncherState returned error: %v", err)
|
||||
}
|
||||
if !state.RunModelUsable {
|
||||
t.Fatal("expected saved run model to be usable from tags inventory")
|
||||
}
|
||||
if state.Integrations["codex"].CurrentModel != "qwen3:8b" {
|
||||
t.Fatalf("expected codex current model from saved config, got %q", state.Integrations["codex"].CurrentModel)
|
||||
}
|
||||
if !state.Integrations["codex"].ModelUsable {
|
||||
t.Fatal("expected saved codex model to be usable from tags inventory")
|
||||
}
|
||||
if got := showCalls.Load(); got != 0 {
|
||||
t.Fatalf("show calls = %d, want 0 for broad launcher state", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRunModel_UsesSavedModelWithoutSelector(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// LaunchModel is the model metadata Launch passes to integration config
|
||||
// writers after resolving selected model names through the per-run inventory.
|
||||
type LaunchModel struct {
|
||||
Name string
|
||||
Remote bool
|
||||
ToolCapable bool
|
||||
Capabilities []modelpkg.Capability
|
||||
ContextLength int
|
||||
MaxOutputTokens int
|
||||
EmbeddingLength int
|
||||
Size int64
|
||||
Details api.ModelDetails
|
||||
}
|
||||
|
||||
type modelInfo = LaunchModel
|
||||
|
||||
// ModelInfo re-exports launcher model inventory details for callers.
|
||||
type ModelInfo = LaunchModel
|
||||
|
||||
func (m LaunchModel) HasCapability(capability modelpkg.Capability) bool {
|
||||
return slices.Contains(m.Capabilities, capability)
|
||||
}
|
||||
|
||||
func (m LaunchModel) WithCloudLimits() LaunchModel {
|
||||
if limit, ok := lookupCloudModelLimit(m.Name); ok {
|
||||
if m.ContextLength <= 0 {
|
||||
m.ContextLength = limit.Context
|
||||
}
|
||||
if m.MaxOutputTokens <= 0 {
|
||||
m.MaxOutputTokens = limit.Output
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
type modelInventory struct {
|
||||
client *api.Client
|
||||
|
||||
mu sync.Mutex
|
||||
loaded bool
|
||||
models []LaunchModel
|
||||
err error
|
||||
}
|
||||
|
||||
func newModelInventory(client *api.Client) *modelInventory {
|
||||
return &modelInventory{client: client}
|
||||
}
|
||||
|
||||
func (i *modelInventory) Load(ctx context.Context) ([]LaunchModel, error) {
|
||||
return i.load(ctx, false)
|
||||
}
|
||||
|
||||
func (i *modelInventory) Refresh(ctx context.Context) ([]LaunchModel, error) {
|
||||
return i.load(ctx, true)
|
||||
}
|
||||
|
||||
func (i *modelInventory) load(ctx context.Context, force bool) ([]LaunchModel, error) {
|
||||
if i == nil || i.client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if i.loaded && !force {
|
||||
return cloneLaunchModels(i.models), i.err
|
||||
}
|
||||
|
||||
resp, err := i.client.List(ctx)
|
||||
if err != nil {
|
||||
i.models = nil
|
||||
i.err = err
|
||||
i.loaded = true
|
||||
return nil, err
|
||||
}
|
||||
|
||||
i.models = make([]LaunchModel, 0, len(resp.Models))
|
||||
for _, model := range resp.Models {
|
||||
i.models = append(i.models, launchModelFromListResponse(model))
|
||||
}
|
||||
i.err = nil
|
||||
i.loaded = true
|
||||
|
||||
return cloneLaunchModels(i.models), i.err
|
||||
}
|
||||
|
||||
func (i *modelInventory) Resolve(ctx context.Context, names []string) []LaunchModel {
|
||||
names = dedupeModelList(names)
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
models, err := i.Load(ctx)
|
||||
if err != nil {
|
||||
models = nil
|
||||
}
|
||||
|
||||
resolved, localMiss := resolveLaunchModels(names, models)
|
||||
if localMiss {
|
||||
if refreshed, err := i.Refresh(ctx); err == nil {
|
||||
resolved, _ = resolveLaunchModels(names, refreshed)
|
||||
}
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func resolveLaunchModels(names []string, models []LaunchModel) ([]LaunchModel, bool) {
|
||||
resolved := make([]LaunchModel, 0, len(names))
|
||||
localMiss := false
|
||||
for _, name := range names {
|
||||
if model, ok := findLaunchModel(models, name); ok {
|
||||
resolved = append(resolved, model.WithCloudLimits())
|
||||
continue
|
||||
}
|
||||
if !isCloudModelName(name) {
|
||||
localMiss = true
|
||||
}
|
||||
resolved = append(resolved, fallbackLaunchModel(name))
|
||||
}
|
||||
return resolved, localMiss
|
||||
}
|
||||
|
||||
func launchModelFromListResponse(model api.ListModelResponse) LaunchModel {
|
||||
return LaunchModel{
|
||||
Name: model.Name,
|
||||
Remote: model.RemoteModel != "",
|
||||
ToolCapable: slices.Contains(model.Capabilities, modelpkg.CapabilityTools),
|
||||
Capabilities: append([]modelpkg.Capability(nil), model.Capabilities...),
|
||||
ContextLength: model.Details.ContextLength,
|
||||
EmbeddingLength: model.Details.EmbeddingLength,
|
||||
Size: model.Size,
|
||||
Details: model.Details,
|
||||
}.WithCloudLimits()
|
||||
}
|
||||
|
||||
func fallbackLaunchModel(name string) LaunchModel {
|
||||
return LaunchModel{Name: name, Remote: isCloudModelName(name)}.WithCloudLimits()
|
||||
}
|
||||
|
||||
func findLaunchModel(models []LaunchModel, name string) (LaunchModel, bool) {
|
||||
for _, model := range models {
|
||||
if launchModelMatches(model.Name, name) {
|
||||
return cloneLaunchModel(model), true
|
||||
}
|
||||
}
|
||||
return LaunchModel{}, false
|
||||
}
|
||||
|
||||
func launchModelMatches(candidate, name string) bool {
|
||||
if candidate == name {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSuffix(candidate, ":latest") == name
|
||||
}
|
||||
|
||||
func cloneLaunchModel(model LaunchModel) LaunchModel {
|
||||
model.Capabilities = append([]modelpkg.Capability(nil), model.Capabilities...)
|
||||
model.Details.Families = append([]string(nil), model.Details.Families...)
|
||||
return model
|
||||
}
|
||||
|
||||
func cloneLaunchModels(models []LaunchModel) []LaunchModel {
|
||||
cloned := make([]LaunchModel, len(models))
|
||||
for i, model := range models {
|
||||
cloned[i] = cloneLaunchModel(model)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func launchModelNames(models []LaunchModel) []string {
|
||||
names := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
if model.Name != "" {
|
||||
names = append(names, model.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func launchModelsFromNames(names []string) []LaunchModel {
|
||||
models := make([]LaunchModel, 0, len(names))
|
||||
for _, name := range names {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
models = append(models, fallbackLaunchModel(name))
|
||||
}
|
||||
return models
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestModelInventoryResolveRefreshesLocalMiss(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/tags" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
calls++
|
||||
if calls == 1 {
|
||||
fmt.Fprint(w, `{"models":[]}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"models":[{"name":"new-model","size":123,"details":{"context_length":65536,"embedding_length":1024},"capabilities":["vision","tools"]}]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
inventory := newModelInventory(api.NewClient(u, srv.Client()))
|
||||
|
||||
got := inventory.Resolve(context.Background(), []string{"new-model"})
|
||||
if calls != 2 {
|
||||
t.Fatalf("List calls = %d, want 2", calls)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Resolve returned %d models, want 1", len(got))
|
||||
}
|
||||
if got[0].Name != "new-model" {
|
||||
t.Fatalf("Name = %q, want new-model", got[0].Name)
|
||||
}
|
||||
if got[0].ContextLength != 65_536 || got[0].EmbeddingLength != 1_024 {
|
||||
t.Fatalf("metadata = context %d embedding %d, want refreshed metadata", got[0].ContextLength, got[0].EmbeddingLength)
|
||||
}
|
||||
if !got[0].HasCapability(modelpkg.CapabilityVision) || !got[0].ToolCapable {
|
||||
t.Fatalf("capabilities = %v toolCapable=%v, want refreshed capabilities", got[0].Capabilities, got[0].ToolCapable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelInventoryResolveDoesNotRefreshCloudMiss(t *testing.T) {
|
||||
calls := 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/tags" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
calls++
|
||||
fmt.Fprint(w, `{"models":[]}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
inventory := newModelInventory(api.NewClient(u, srv.Client()))
|
||||
|
||||
got := inventory.Resolve(context.Background(), []string{"glm-5.1:cloud"})
|
||||
if calls != 1 {
|
||||
t.Fatalf("List calls = %d, want 1", calls)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Resolve returned %d models, want 1", len(got))
|
||||
}
|
||||
if got[0].Name != "glm-5.1:cloud" || !got[0].Remote {
|
||||
t.Fatalf("resolved model = %#v, want cloud fallback", got[0])
|
||||
}
|
||||
if got[0].ContextLength <= 0 || got[0].MaxOutputTokens <= 0 {
|
||||
t.Fatalf("cloud limits not applied: %#v", got[0])
|
||||
}
|
||||
}
|
||||
+21
-24
@@ -23,10 +23,10 @@ import (
|
||||
)
|
||||
|
||||
var recommendedModels = []ModelItem{
|
||||
{Name: "kimi-k2.6:cloud", Description: "State-of-the-art coding, long-horizon execution, and multimodal agent swarm capability", Recommended: true, ContextLength: 262_144, MaxOutputTokens: 262_144},
|
||||
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true, ContextLength: 262_144, MaxOutputTokens: 32_768},
|
||||
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true, ContextLength: 202_752, MaxOutputTokens: 131_072},
|
||||
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true, ContextLength: 204_800, MaxOutputTokens: 128_000},
|
||||
{Name: "kimi-k2.6:cloud", Description: "State-of-the-art coding, long-horizon execution, and multimodal agent swarm capability", Recommended: true, Details: api.ModelDetails{ContextLength: 262_144}, MaxOutputTokens: 262_144},
|
||||
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true, Details: api.ModelDetails{ContextLength: 262_144}, MaxOutputTokens: 32_768},
|
||||
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true, Details: api.ModelDetails{ContextLength: 202_752}, MaxOutputTokens: 131_072},
|
||||
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true, Details: api.ModelDetails{ContextLength: 204_800}, MaxOutputTokens: 128_000},
|
||||
{Name: "gemma4", Description: "Reasoning and code generation locally", Recommended: true, VRAMBytes: 12 * format.GigaByte},
|
||||
{Name: "qwen3.5", Description: "Reasoning, coding, and visual understanding locally", Recommended: true, VRAMBytes: 14 * format.GigaByte},
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func setDynamicCloudModelLimits(limits map[string]cloudModelLimit) {
|
||||
func cloudModelLimitsFromRecommendations(recommendations []ModelItem) map[string]cloudModelLimit {
|
||||
limits := make(map[string]cloudModelLimit, len(recommendations))
|
||||
for _, rec := range recommendations {
|
||||
if !isCloudModelName(rec.Name) || rec.ContextLength <= 0 || rec.MaxOutputTokens <= 0 {
|
||||
if !isCloudModelName(rec.Name) || rec.Details.ContextLength <= 0 || rec.MaxOutputTokens <= 0 {
|
||||
continue
|
||||
}
|
||||
base, stripped := modelref.StripCloudSourceTag(rec.Name)
|
||||
@@ -123,7 +123,7 @@ func cloudModelLimitsFromRecommendations(recommendations []ModelItem) map[string
|
||||
continue
|
||||
}
|
||||
limits[base] = cloudModelLimit{
|
||||
Context: rec.ContextLength,
|
||||
Context: rec.Details.ContextLength,
|
||||
Output: rec.MaxOutputTokens,
|
||||
}
|
||||
}
|
||||
@@ -299,18 +299,17 @@ func pullMissingModel(ctx context.Context, client *api.Client, model string) err
|
||||
}
|
||||
|
||||
// prepareEditorIntegration persists models and applies editor-managed config files.
|
||||
func prepareEditorIntegration(name string, editor Editor, models []string) error {
|
||||
func prepareEditorIntegration(name string, editor Editor, models []LaunchModel) error {
|
||||
if err := editor.Edit(models); err != nil {
|
||||
return fmt.Errorf("setup failed: %w", err)
|
||||
}
|
||||
if err := config.SaveIntegration(name, models); err != nil {
|
||||
if err := config.SaveIntegration(name, launchModelNames(models)); err != nil {
|
||||
return fmt.Errorf("failed to save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, model string, models []string) error {
|
||||
models = dedupeModelList(append([]string{model}, models...))
|
||||
func prepareManagedSingleIntegration(name string, managed ManagedSingleModel, model string, models []LaunchModel) error {
|
||||
var err error
|
||||
if withModels, ok := managed.(ManagedModelListConfigurer); ok {
|
||||
err = withModels.ConfigureWithModels(model, models)
|
||||
@@ -365,11 +364,11 @@ func buildModelListWithRecommendations(existing []modelInfo, recommendations []M
|
||||
}
|
||||
displayName := strings.TrimSuffix(m.Name, ":latest")
|
||||
existingModels[displayName] = true
|
||||
item := ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]}
|
||||
if rec, ok := recByName[displayName]; ok {
|
||||
item = copyModelRecommendationFields(displayName, rec)
|
||||
items = append(items, modelItemFromInventory(displayName, m, copyModelRecommendationFields(displayName, rec)))
|
||||
} else {
|
||||
items = append(items, modelItemFromInventory(displayName, m, ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]}))
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
for _, rec := range recommendations {
|
||||
@@ -483,22 +482,20 @@ func copyModelRecommendationFields(name string, rec ModelItem) ModelItem {
|
||||
return rec
|
||||
}
|
||||
|
||||
func modelItemFromInventory(name string, info modelInfo, item ModelItem) ModelItem {
|
||||
item.Name = name
|
||||
item.ToolCapable = info.ToolCapable
|
||||
item.Capabilities = slices.Clone(info.Capabilities)
|
||||
item.Size = info.Size
|
||||
item.Details = info.Details
|
||||
return item
|
||||
}
|
||||
|
||||
// isCloudModelName reports whether the model name has an explicit cloud source.
|
||||
func isCloudModelName(name string) bool {
|
||||
return modelref.HasExplicitCloudSource(name)
|
||||
}
|
||||
|
||||
// filterCloudModels drops remote-only models from the given inventory.
|
||||
func filterCloudModels(existing []modelInfo) []modelInfo {
|
||||
filtered := existing[:0]
|
||||
for _, m := range existing {
|
||||
if !m.Remote {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// filterCloudItems removes cloud models from selection items.
|
||||
func filterCloudItems(items []ModelItem) []ModelItem {
|
||||
filtered := items[:0]
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/format"
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestBuildModelList_UsesInventoryMetadataForInstalledModels(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{
|
||||
Name: "custom-tools:latest",
|
||||
ToolCapable: true,
|
||||
Capabilities: []modelpkg.Capability{modelpkg.CapabilityCompletion, modelpkg.CapabilityTools, modelpkg.CapabilityThinking},
|
||||
Size: 7500 * format.MegaByte,
|
||||
Details: api.ModelDetails{
|
||||
ParameterSize: "8B",
|
||||
QuantizationLevel: "Q4_K_M",
|
||||
ContextLength: 131_072,
|
||||
EmbeddingLength: 4096,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
var got ModelItem
|
||||
for _, item := range items {
|
||||
if item.Name == "custom-tools" {
|
||||
got = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if got.Name == "" {
|
||||
t.Fatal("custom-tools not found in items")
|
||||
}
|
||||
if !got.ToolCapable {
|
||||
t.Fatal("expected installed model to preserve tool capability from tags metadata")
|
||||
}
|
||||
if got.Details.ContextLength != 131_072 {
|
||||
t.Fatalf("Details.ContextLength = %d, want 131072", got.Details.ContextLength)
|
||||
}
|
||||
if got.Size != 7500*format.MegaByte {
|
||||
t.Fatalf("Size = %d, want %d", got.Size, 7500*format.MegaByte)
|
||||
}
|
||||
if got.Description != "" {
|
||||
t.Fatalf("Description = %q, want empty for installed model without recommendation copy", got.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_InstalledRecommendedPreservesRecommendationAndMetadata(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{
|
||||
Name: "qwen3.5",
|
||||
ToolCapable: true,
|
||||
Capabilities: []modelpkg.Capability{modelpkg.CapabilityCompletion, modelpkg.CapabilityTools, modelpkg.CapabilityVision},
|
||||
Size: 14 * format.GigaByte,
|
||||
Details: api.ModelDetails{ContextLength: 262_144},
|
||||
},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
var got ModelItem
|
||||
for _, item := range items {
|
||||
if item.Name == "qwen3.5" {
|
||||
got = item
|
||||
break
|
||||
}
|
||||
}
|
||||
if got.Name == "" {
|
||||
t.Fatal("qwen3.5 not found in items")
|
||||
}
|
||||
if !got.Recommended || !got.ToolCapable {
|
||||
t.Fatalf("recommended/tool metadata = %v/%v, want true/true", got.Recommended, got.ToolCapable)
|
||||
}
|
||||
if got.Details.ContextLength != 262_144 {
|
||||
t.Fatalf("Details.ContextLength = %d, want 262144", got.Details.ContextLength)
|
||||
}
|
||||
if got.Description != "Reasoning, coding, and visual understanding locally" {
|
||||
t.Fatalf("Description = %q, want recommendation description", got.Description)
|
||||
}
|
||||
}
|
||||
+17
-55
@@ -1,7 +1,6 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -10,21 +9,15 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
const defaultGatewayPort = 18789
|
||||
|
||||
// Bound model capability probing so launch/config cannot hang on slow/unreachable API calls.
|
||||
var openclawModelShowTimeout = 5 * time.Second
|
||||
|
||||
// openclawFreshInstall is set to true when ensureOpenclawInstalled performs an install
|
||||
var openclawFreshInstall bool
|
||||
|
||||
@@ -34,7 +27,7 @@ type Openclaw struct{}
|
||||
|
||||
func (c *Openclaw) String() string { return "OpenClaw" }
|
||||
|
||||
func (c *Openclaw) Run(model string, args []string) error {
|
||||
func (c *Openclaw) Run(model string, _ []LaunchModel, args []string) error {
|
||||
bin, err := ensureOpenclawInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -656,7 +649,7 @@ func (c *Openclaw) Paths() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Openclaw) Edit(models []string) error {
|
||||
func (c *Openclaw) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -710,13 +703,11 @@ func (c *Openclaw) Edit(models []string) error {
|
||||
}
|
||||
}
|
||||
|
||||
client, _ := api.ClientFromEnvironment()
|
||||
|
||||
var newModels []any
|
||||
for _, m := range models {
|
||||
entry, _ := openclawModelConfig(context.Background(), client, m)
|
||||
entry, _ := openclawModelConfig(m)
|
||||
// Merge existing fields (user customizations)
|
||||
if existing, ok := existingByID[m]; ok {
|
||||
if existing, ok := existingByID[m.Name]; ok {
|
||||
for k, v := range existing {
|
||||
if _, isNew := entry[k]; !isNew {
|
||||
entry[k] = v
|
||||
@@ -744,7 +735,7 @@ func (c *Openclaw) Edit(models []string) error {
|
||||
if modelConfig == nil {
|
||||
modelConfig = make(map[string]any)
|
||||
}
|
||||
modelConfig["primary"] = "ollama/" + models[0]
|
||||
modelConfig["primary"] = "ollama/" + models[0].Name
|
||||
defaults["model"] = modelConfig
|
||||
agents["defaults"] = defaults
|
||||
config["agents"] = agents
|
||||
@@ -759,7 +750,7 @@ func (c *Openclaw) Edit(models []string) error {
|
||||
|
||||
// Clear any per-session model overrides so the new primary takes effect
|
||||
// immediately rather than being shadowed by a cached modelOverride.
|
||||
clearSessionModelOverride(models[0])
|
||||
clearSessionModelOverride(models[0].Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -936,10 +927,10 @@ func configureOllamaWebSearch() {
|
||||
|
||||
// openclawModelConfig builds an OpenClaw model config entry with capability detection.
|
||||
// The second return value indicates whether the model is a cloud (remote) model.
|
||||
func openclawModelConfig(ctx context.Context, client *api.Client, modelID string) (map[string]any, bool) {
|
||||
func openclawModelConfig(model LaunchModel) (map[string]any, bool) {
|
||||
entry := map[string]any{
|
||||
"id": modelID,
|
||||
"name": modelID,
|
||||
"id": model.Name,
|
||||
"name": model.Name,
|
||||
"input": []any{"text"},
|
||||
"cost": map[string]any{
|
||||
"input": 0,
|
||||
@@ -949,53 +940,24 @@ func openclawModelConfig(ctx context.Context, client *api.Client, modelID string
|
||||
},
|
||||
}
|
||||
|
||||
if client == nil {
|
||||
return entry, false
|
||||
}
|
||||
|
||||
showCtx := ctx
|
||||
if _, hasDeadline := ctx.Deadline(); !hasDeadline {
|
||||
var cancel context.CancelFunc
|
||||
showCtx, cancel = context.WithTimeout(ctx, openclawModelShowTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
resp, err := client.Show(showCtx, &api.ShowRequest{Model: modelID})
|
||||
if err != nil {
|
||||
return entry, false
|
||||
}
|
||||
|
||||
// Set input types based on vision capability
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
|
||||
if model.HasCapability("vision") {
|
||||
entry["input"] = []any{"text", "image"}
|
||||
}
|
||||
|
||||
// Set reasoning based on thinking capability
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
|
||||
if model.HasCapability("thinking") {
|
||||
entry["reasoning"] = true
|
||||
}
|
||||
|
||||
// Cloud models: use hardcoded limits for context/output tokens.
|
||||
// Capability detection above still applies (vision, thinking).
|
||||
if resp.RemoteModel != "" {
|
||||
if l, ok := lookupCloudModelLimit(modelID); ok {
|
||||
entry["contextWindow"] = l.Context
|
||||
entry["maxTokens"] = l.Output
|
||||
}
|
||||
return entry, true
|
||||
if model.ContextLength > 0 {
|
||||
entry["contextWindow"] = model.ContextLength
|
||||
}
|
||||
if model.MaxOutputTokens > 0 {
|
||||
entry["maxTokens"] = model.MaxOutputTokens
|
||||
}
|
||||
|
||||
// Extract context window from ModelInfo (local models only)
|
||||
for key, val := range resp.ModelInfo {
|
||||
if strings.HasSuffix(key, ".context_length") {
|
||||
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
|
||||
entry["contextWindow"] = int(ctxLen)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return entry, false
|
||||
return entry, model.Remote || isCloudModelName(model.Name)
|
||||
}
|
||||
|
||||
func (c *Openclaw) Models() []string {
|
||||
|
||||
+62
-211
@@ -2,12 +2,9 @@ package launch
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -16,8 +13,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestOpenclawIntegration(t *testing.T) {
|
||||
@@ -78,7 +75,7 @@ func TestOpenclawRunPassthroughArgs(t *testing.T) {
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", []string{"gateway", "--someflag"}); err != nil {
|
||||
if err := c.Run("llama3.2", nil, []string{"gateway", "--someflag"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -152,7 +149,7 @@ fi
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", nil); err != nil {
|
||||
if err := c.Run("llama3.2", nil, nil); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -224,7 +221,7 @@ func TestOpenclawRun_SetupLaterContinuesToGatewayAndTUI(t *testing.T) {
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", nil); err != nil {
|
||||
if err := c.Run("llama3.2", nil, nil); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -287,7 +284,7 @@ exit 0
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", []string{"status"}); err != nil {
|
||||
if err := c.Run("llama3.2", nil, []string{"status"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -367,7 +364,7 @@ exit 0
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
c := &Openclaw{}
|
||||
if err := c.Run("llama3.2", []string{"tui"}); err != nil {
|
||||
if err := c.Run("llama3.2", nil, []string{"tui"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -617,7 +614,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
|
||||
t.Run("fresh install", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2")
|
||||
@@ -626,7 +623,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
|
||||
t.Run("multiple models - first is primary", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2")
|
||||
@@ -638,7 +635,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
cleanup()
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"models":{"providers":{"anthropic":{"apiKey":"xxx"}}}}`), 0o644)
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(configPath)
|
||||
@@ -655,7 +652,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
cleanup()
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"theme":"dark","mcp":{"servers":{}}}`), 0o644)
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(configPath)
|
||||
@@ -671,7 +668,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
|
||||
t.Run("preserve user customizations on models", func(t *testing.T) {
|
||||
cleanup()
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
// User adds custom field
|
||||
data, _ := os.ReadFile(configPath)
|
||||
@@ -687,7 +684,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
os.WriteFile(configPath, configData, 0o644)
|
||||
|
||||
// Re-run Edit
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
data, _ = os.ReadFile(configPath)
|
||||
json.Unmarshal(data, &cfg)
|
||||
@@ -703,8 +700,8 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
|
||||
t.Run("edit replaces models list", func(t *testing.T) {
|
||||
cleanup()
|
||||
c.Edit([]string{"llama3.2", "mistral"})
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2", "mistral"))
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2")
|
||||
assertOpenclawModelNotExists(t, configPath, "mistral")
|
||||
@@ -716,7 +713,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
original := `{"existing":"data"}`
|
||||
os.WriteFile(configPath, []byte(original), 0o644)
|
||||
|
||||
c.Edit([]string{})
|
||||
c.Edit(testLaunchModels())
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
if string(data) != original {
|
||||
@@ -729,7 +726,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{corrupted`), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -745,7 +742,7 @@ func TestOpenclawEdit(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"models":"not a map"}`), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2")
|
||||
@@ -925,7 +922,7 @@ func TestOpenclawEditSchemaFields(t *testing.T) {
|
||||
setTestHome(t, tmpDir)
|
||||
configPath := filepath.Join(tmpDir, ".openclaw", "openclaw.json")
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -966,7 +963,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
|
||||
|
||||
t.Run("model with colon tag", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"llama3.2:70b"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2:70b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "llama3.2:70b")
|
||||
@@ -975,7 +972,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
|
||||
|
||||
t.Run("model with slash", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"library/model:tag"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("library/model:tag")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "library/model:tag")
|
||||
@@ -984,7 +981,7 @@ func TestOpenclawEditModelNames(t *testing.T) {
|
||||
|
||||
t.Run("model with hyphen", func(t *testing.T) {
|
||||
cleanup()
|
||||
if err := c.Edit([]string{"test-model"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("test-model")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertOpenclawModelExists(t, configPath, "test-model")
|
||||
@@ -1004,7 +1001,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{"model":{"primary":"old"},"temperature":0.7}}}`), 0o644)
|
||||
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
var cfg map[string]any
|
||||
@@ -1021,7 +1018,7 @@ func TestOpenclawEditAgentsPreservation(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(`{"agents":{"defaults":{},"custom-agent":{"foo":"bar"}}}`), 0o644)
|
||||
|
||||
c.Edit([]string{"llama3.2"})
|
||||
c.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
var cfg map[string]any
|
||||
@@ -1061,7 +1058,7 @@ func TestOpenclawEdit_RoundTrip(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2", "mistral"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2", "mistral")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1107,10 +1104,10 @@ func TestOpenclawEdit_Idempotent(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte(testOpenclawFixture), 0o644)
|
||||
|
||||
c.Edit([]string{"llama3.2", "mistral"})
|
||||
c.Edit(testLaunchModels("llama3.2", "mistral"))
|
||||
firstData, _ := os.ReadFile(configPath)
|
||||
|
||||
c.Edit([]string{"llama3.2", "mistral"})
|
||||
c.Edit(testLaunchModels("llama3.2", "mistral"))
|
||||
secondData, _ := os.ReadFile(configPath)
|
||||
|
||||
if string(firstData) != string(secondData) {
|
||||
@@ -1133,7 +1130,7 @@ func TestOpenclawEdit_MultipleConsecutiveEdits(t *testing.T) {
|
||||
if i%2 == 0 {
|
||||
models = []string{"model-x", "model-y", "model-z"}
|
||||
}
|
||||
if err := c.Edit(models); err != nil {
|
||||
if err := c.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("edit %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
@@ -1162,7 +1159,7 @@ func TestOpenclawEdit_BackupCreated(t *testing.T) {
|
||||
original := fmt.Sprintf(`{"theme": "%s"}`, uniqueMarker)
|
||||
os.WriteFile(configPath, []byte(original), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"model-a"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1284,7 +1281,7 @@ func TestOpenclawLegacyPaths(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(newDir, "openclaw.json"), []byte(`{"theme":"new"}`), 0o644)
|
||||
os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"legacy"}`), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1303,7 +1300,7 @@ func TestOpenclawLegacyPaths(t *testing.T) {
|
||||
os.MkdirAll(legacyDir, 0o755)
|
||||
os.WriteFile(filepath.Join(legacyDir, "clawdbot.json"), []byte(`{"theme":"dark"}`), 0o644)
|
||||
|
||||
if err := c.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1331,7 +1328,7 @@ func TestOpenclawEdit_CreatesDirectoryIfMissing(t *testing.T) {
|
||||
t.Fatal("directory should not exist before test")
|
||||
}
|
||||
|
||||
if err := c.Edit([]string{"model-a"}); err != nil {
|
||||
if err := c.Edit(testLaunchModels("model-a")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -2248,8 +2245,8 @@ func TestPrintOpenclawReady(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOpenclawModelConfig(t *testing.T) {
|
||||
t.Run("nil client returns base config", func(t *testing.T) {
|
||||
cfg, _ := openclawModelConfig(context.Background(), nil, "llama3.2")
|
||||
t.Run("minimal model returns base config", func(t *testing.T) {
|
||||
cfg, _ := openclawModelConfig(fallbackLaunchModel("llama3.2"))
|
||||
|
||||
if cfg["id"] != "llama3.2" {
|
||||
t.Errorf("id = %v, want llama3.2", cfg["id"])
|
||||
@@ -2260,29 +2257,17 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
if cfg["cost"] == nil {
|
||||
t.Error("cost should be set")
|
||||
}
|
||||
// Should not have capability fields without API
|
||||
// Should not have capability fields without inventory metadata.
|
||||
if _, ok := cfg["reasoning"]; ok {
|
||||
t.Error("reasoning should not be set without API")
|
||||
t.Error("reasoning should not be set without metadata")
|
||||
}
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set without API")
|
||||
t.Error("contextWindow should not be set without metadata")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets vision input when model has vision capability", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{"llama.context_length":4096}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "llava:7b")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{"vision"}, ContextLength: 4096})
|
||||
|
||||
input, ok := cfg["input"].([]any)
|
||||
if !ok || len(input) != 2 {
|
||||
@@ -2291,19 +2276,7 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets text-only input when model lacks vision", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{"completion"}})
|
||||
|
||||
input, ok := cfg["input"].([]any)
|
||||
if !ok || len(input) != 1 {
|
||||
@@ -2315,39 +2288,15 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets reasoning when model has thinking capability", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "qwq")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{"thinking"}})
|
||||
|
||||
if cfg["reasoning"] != true {
|
||||
t.Error("expected reasoning = true for thinking model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extracts context window from model info", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "llama3.2")
|
||||
t.Run("sets context window from inventory metadata", func(t *testing.T) {
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072})
|
||||
|
||||
if cfg["contextWindow"] != 131072 {
|
||||
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
|
||||
@@ -2355,19 +2304,11 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("handles all capabilities together", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "qwen3-vision")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{
|
||||
Name: "qwen3-vision",
|
||||
Capabilities: []model.Capability{"vision", "thinking"},
|
||||
ContextLength: 32768,
|
||||
})
|
||||
|
||||
input, ok := cfg["input"].([]any)
|
||||
if !ok || len(input) != 2 {
|
||||
@@ -2381,17 +2322,8 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns base config when show fails", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"model not found"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "missing-model")
|
||||
t.Run("returns base config when metadata is unavailable", func(t *testing.T) {
|
||||
cfg, _ := openclawModelConfig(fallbackLaunchModel("missing-model"))
|
||||
|
||||
if cfg["id"] != "missing-model" {
|
||||
t.Errorf("id = %v, want missing-model", cfg["id"])
|
||||
@@ -2401,62 +2333,15 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
t.Error("input should always be set")
|
||||
}
|
||||
if _, ok := cfg["reasoning"]; ok {
|
||||
t.Error("reasoning should not be set when show fails")
|
||||
t.Error("reasoning should not be set when metadata is unavailable")
|
||||
}
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set when show fails")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("times out slow show and returns base config", func(t *testing.T) {
|
||||
oldTimeout := openclawModelShowTimeout
|
||||
openclawModelShowTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { openclawModelShowTimeout = oldTimeout })
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{"llama.context_length":4096}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
start := time.Now()
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "slow-model")
|
||||
elapsed := time.Since(start)
|
||||
if elapsed >= 250*time.Millisecond {
|
||||
t.Fatalf("openclawModelConfig took too long: %v", elapsed)
|
||||
}
|
||||
if cfg["id"] != "slow-model" {
|
||||
t.Errorf("id = %v, want slow-model", cfg["id"])
|
||||
}
|
||||
if _, ok := cfg["reasoning"]; ok {
|
||||
t.Error("reasoning should not be set on timeout")
|
||||
}
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set on timeout")
|
||||
t.Error("contextWindow should not be set when metadata is unavailable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("skips zero context length", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, _ := openclawModelConfig(context.Background(), client, "test-model")
|
||||
cfg, _ := openclawModelConfig(LaunchModel{Name: "test-model", ContextLength: 0})
|
||||
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set for zero value")
|
||||
@@ -2464,21 +2349,7 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cloud model uses hardcoded limits", func(t *testing.T) {
|
||||
// Use a model name that's in cloudModelLimits and make the server
|
||||
// report it as a remote/cloud model
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{},"remote_model":"minimax-m2.7"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, isCloud := openclawModelConfig(context.Background(), client, "minimax-m2.7:cloud")
|
||||
cfg, isCloud := openclawModelConfig(fallbackLaunchModel("minimax-m2.7:cloud"))
|
||||
|
||||
if !isCloud {
|
||||
t.Error("expected isCloud = true for cloud model")
|
||||
@@ -2492,21 +2363,11 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cloud model with vision capability gets image input", func(t *testing.T) {
|
||||
// Regression test: cloud models must not skip capability detection.
|
||||
// A cloud model that reports vision capability should have input: [text, image].
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{},"remote_model":"qwen3-vl"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwen3-vl:235b-cloud")
|
||||
cfg, isCloud := openclawModelConfig(LaunchModel{
|
||||
Name: "qwen3-vl:235b-cloud",
|
||||
Remote: true,
|
||||
Capabilities: []model.Capability{"vision"},
|
||||
}.WithCloudLimits())
|
||||
|
||||
if !isCloud {
|
||||
t.Error("expected isCloud = true for cloud vision model")
|
||||
@@ -2518,21 +2379,11 @@ func TestOpenclawModelConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cloud model with thinking capability gets reasoning flag", func(t *testing.T) {
|
||||
// Regression test: cloud models must not skip capability detection.
|
||||
// A cloud model that reports thinking capability should have reasoning: true.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{},"remote_model":"qwq-cloud"}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg, isCloud := openclawModelConfig(context.Background(), client, "qwq:cloud")
|
||||
cfg, isCloud := openclawModelConfig(LaunchModel{
|
||||
Name: "qwq:cloud",
|
||||
Remote: true,
|
||||
Capabilities: []model.Capability{"thinking"},
|
||||
})
|
||||
|
||||
if !isCloud {
|
||||
t.Error("expected isCloud = true for cloud thinking model")
|
||||
|
||||
+67
-21
@@ -43,7 +43,7 @@ func findOpenCode() (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (o *OpenCode) Run(model string, args []string) error {
|
||||
func (o *OpenCode) Run(model string, models []LaunchModel, args []string) error {
|
||||
opencodePath, ok := findOpenCode()
|
||||
if !ok {
|
||||
return fmt.Errorf("opencode is not installed, install from https://opencode.ai")
|
||||
@@ -54,7 +54,7 @@ func (o *OpenCode) Run(model string, args []string) error {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
if content := o.resolveContent(model); content != "" {
|
||||
if content := o.resolveContent(model, models); content != "" {
|
||||
cmd.Env = append(cmd.Env, "OPENCODE_CONFIG_CONTENT="+content)
|
||||
}
|
||||
return cmd.Run()
|
||||
@@ -63,21 +63,57 @@ func (o *OpenCode) Run(model string, args []string) error {
|
||||
// resolveContent returns the inline config to send via OPENCODE_CONFIG_CONTENT.
|
||||
// Returns content built by Edit if available, otherwise builds from model.json
|
||||
// with the requested model as primary (e.g. re-launch with saved config).
|
||||
func (o *OpenCode) resolveContent(model string) string {
|
||||
func (o *OpenCode) resolveContent(model string, models []LaunchModel) string {
|
||||
if o.configContent != "" {
|
||||
return o.configContent
|
||||
}
|
||||
models := readModelJSONModels()
|
||||
if !slices.Contains(models, model) {
|
||||
models = append([]string{model}, models...)
|
||||
resolvedModels := resolveOpenCodeRunModels(model, models, readModelJSONModels())
|
||||
if len(resolvedModels) == 0 {
|
||||
return ""
|
||||
}
|
||||
content, err := buildInlineConfig(model, models)
|
||||
content, err := buildInlineConfig(resolvedModels[0], resolvedModels)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
func resolveOpenCodeRunModels(primary string, models []LaunchModel, stateModels []string) []LaunchModel {
|
||||
if primary == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
resolved := make([]LaunchModel, 0, 1+len(models)+len(stateModels))
|
||||
appendModel := func(name string) {
|
||||
if name == "" || hasLaunchModel(resolved, name) {
|
||||
return
|
||||
}
|
||||
if model, ok := findLaunchModel(models, name); ok {
|
||||
resolved = append(resolved, model)
|
||||
return
|
||||
}
|
||||
resolved = append(resolved, fallbackLaunchModel(name))
|
||||
}
|
||||
|
||||
appendModel(primary)
|
||||
for _, model := range models {
|
||||
appendModel(model.Name)
|
||||
}
|
||||
for _, model := range stateModels {
|
||||
appendModel(model)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func hasLaunchModel(models []LaunchModel, name string) bool {
|
||||
for _, model := range models {
|
||||
if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (o *OpenCode) Paths() []string {
|
||||
sp, err := openCodeStatePath()
|
||||
if err != nil {
|
||||
@@ -100,12 +136,13 @@ func openCodeStatePath() (string, error) {
|
||||
return filepath.Join(home, ".local", "state", "opencode", "model.json"), nil
|
||||
}
|
||||
|
||||
func (o *OpenCode) Edit(modelList []string) error {
|
||||
func (o *OpenCode) Edit(models []LaunchModel) error {
|
||||
modelList := launchModelNames(models)
|
||||
if len(modelList) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := buildInlineConfig(modelList[0], modelList)
|
||||
content, err := buildInlineConfig(models[0], models)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -172,10 +209,11 @@ func (o *OpenCode) Models() []string {
|
||||
|
||||
// buildInlineConfig produces the JSON string for OPENCODE_CONFIG_CONTENT.
|
||||
// primary is the model to launch with, models is the full list of available models.
|
||||
func buildInlineConfig(primary string, models []string) (string, error) {
|
||||
if primary == "" || len(models) == 0 {
|
||||
func buildInlineConfig(primary LaunchModel, models []LaunchModel) (string, error) {
|
||||
if primary.Name == "" || len(models) == 0 {
|
||||
return "", fmt.Errorf("buildInlineConfig: primary and models are required")
|
||||
}
|
||||
|
||||
config := map[string]any{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": map[string]any{
|
||||
@@ -188,7 +226,7 @@ func buildInlineConfig(primary string, models []string) (string, error) {
|
||||
"models": buildModelEntries(models),
|
||||
},
|
||||
},
|
||||
"model": "ollama/" + primary,
|
||||
"model": "ollama/" + primary.Name,
|
||||
}
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
@@ -228,21 +266,29 @@ func readModelJSONModels() []string {
|
||||
return models
|
||||
}
|
||||
|
||||
func buildModelEntries(modelList []string) map[string]any {
|
||||
func buildModelEntries(modelList []LaunchModel) map[string]any {
|
||||
models := make(map[string]any)
|
||||
for _, model := range modelList {
|
||||
entry := map[string]any{
|
||||
"name": model,
|
||||
"name": model.Name,
|
||||
}
|
||||
if isCloudModelName(model) {
|
||||
if l, ok := lookupCloudModelLimit(model); ok {
|
||||
entry["limit"] = map[string]any{
|
||||
"context": l.Context,
|
||||
"output": l.Output,
|
||||
}
|
||||
if model.HasCapability("vision") {
|
||||
entry["modalities"] = map[string]any{
|
||||
"input": []string{"text", "image"},
|
||||
"output": []string{"text"},
|
||||
}
|
||||
}
|
||||
models[model] = entry
|
||||
if model.ContextLength > 0 || model.MaxOutputTokens > 0 {
|
||||
limit := make(map[string]any)
|
||||
if model.ContextLength > 0 {
|
||||
limit["context"] = model.ContextLength
|
||||
}
|
||||
if model.MaxOutputTokens > 0 {
|
||||
limit["output"] = model.MaxOutputTokens
|
||||
}
|
||||
entry["limit"] = limit
|
||||
}
|
||||
models[model.Name] = entry
|
||||
}
|
||||
return models
|
||||
}
|
||||
+108
-24
@@ -7,6 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
func TestOpenCodeIntegration(t *testing.T) {
|
||||
@@ -31,7 +33,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("builds config content with provider", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -65,7 +67,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("multiple models", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"llama3.2", "qwen3:32b"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("llama3.2", "qwen3:32b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("empty models is no-op", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{}); err != nil {
|
||||
if err := o.Edit(testLaunchModels()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.configContent != "" {
|
||||
@@ -102,7 +104,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
o := &OpenCode{}
|
||||
o.Edit([]string{"llama3.2"})
|
||||
o.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".config", "opencode")
|
||||
|
||||
@@ -117,7 +119,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("cloud model has limits", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -144,7 +146,7 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Run("local model has no limits", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
o := &OpenCode{}
|
||||
o.Edit([]string{"llama3.2"})
|
||||
o.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(o.configContent), &cfg)
|
||||
@@ -157,6 +159,43 @@ func TestOpenCodeEdit(t *testing.T) {
|
||||
t.Errorf("local model should not have limit, got %v", entry["limit"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("vision model gets image input modalities", func(t *testing.T) {
|
||||
models := buildModelEntries([]LaunchModel{{Name: "gemma4:26b", Capabilities: []model.Capability{"vision"}}})
|
||||
entry, _ := models["gemma4:26b"].(map[string]any)
|
||||
modalities, _ := entry["modalities"].(map[string]any)
|
||||
input, _ := modalities["input"].([]string)
|
||||
output, _ := modalities["output"].([]string)
|
||||
|
||||
if len(input) != 2 || input[0] != "text" || input[1] != "image" {
|
||||
t.Fatalf("modalities.input = %v, want [text image]", input)
|
||||
}
|
||||
if len(output) != 1 || output[0] != "text" {
|
||||
t.Fatalf("modalities.output = %v, want [text]", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildModelEntries(t *testing.T) {
|
||||
t.Run("defaults to model name without capabilities", func(t *testing.T) {
|
||||
models := buildModelEntries(testLaunchModels("llama3.2"))
|
||||
entry, _ := models["llama3.2"].(map[string]any)
|
||||
if entry["name"] != "llama3.2" {
|
||||
t.Fatalf("name = %v, want llama3.2", entry["name"])
|
||||
}
|
||||
if _, ok := entry["modalities"]; ok {
|
||||
t.Fatalf("modalities should not be set without capabilities, got %v", entry["modalities"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses context and output limits from metadata", func(t *testing.T) {
|
||||
models := buildModelEntries([]LaunchModel{{Name: "glm-5:cloud", ContextLength: 202_752, MaxOutputTokens: 131_072}})
|
||||
entry, _ := models["glm-5:cloud"].(map[string]any)
|
||||
limit, _ := entry["limit"].(map[string]any)
|
||||
if limit["context"] != 202_752 || limit["output"] != 131_072 {
|
||||
t.Fatalf("limit = %v, want context/output", limit)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenCodeModels_ReturnsNil(t *testing.T) {
|
||||
@@ -284,7 +323,7 @@ func TestOpenCodeEdit_CloudModelLimitStructure(t *testing.T) {
|
||||
|
||||
expected := cloudModelLimits["glm-4.7"]
|
||||
|
||||
if err := o.Edit([]string{"glm-4.7:cloud"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("glm-4.7:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -314,7 +353,7 @@ func TestOpenCodeEdit_SpecialCharsInModelName(t *testing.T) {
|
||||
|
||||
specialModel := `model-with-"quotes"`
|
||||
|
||||
err := o.Edit([]string{specialModel})
|
||||
err := o.Edit(testLaunchModels(specialModel))
|
||||
if err != nil {
|
||||
t.Fatalf("Edit with special chars failed: %v", err)
|
||||
}
|
||||
@@ -407,7 +446,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"gemma4"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("gemma4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
editContent := o.configContent
|
||||
@@ -422,7 +461,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
data, _ := json.MarshalIndent(state, "", " ")
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
got := o.resolveContent("gemma4")
|
||||
got := o.resolveContent("gemma4", nil)
|
||||
if got != editContent {
|
||||
t.Errorf("resolveContent returned different content than Edit set\ngot: %s\nwant: %s", got, editContent)
|
||||
}
|
||||
@@ -444,7 +483,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
content := o.resolveContent("llama3.2")
|
||||
content := o.resolveContent("llama3.2", nil)
|
||||
if content == "" {
|
||||
t.Fatal("resolveContent returned empty")
|
||||
}
|
||||
@@ -478,7 +517,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
content := o.resolveContent("qwen3:32b")
|
||||
content := o.resolveContent("qwen3:32b", nil)
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(content), &cfg)
|
||||
@@ -502,7 +541,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
content := o.resolveContent("gemma4")
|
||||
content := o.resolveContent("gemma4", nil)
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(content), &cfg)
|
||||
@@ -522,11 +561,56 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
o := &OpenCode{}
|
||||
if got := o.resolveContent(""); got != "" {
|
||||
if got := o.resolveContent("", nil); got != "" {
|
||||
t.Errorf("resolveContent(\"\") = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses run model metadata when Edit was not called", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
stateDir := filepath.Join(tmpDir, ".local", "state", "opencode")
|
||||
os.MkdirAll(stateDir, 0o755)
|
||||
state := map[string]any{
|
||||
"recent": []any{
|
||||
map[string]any{"providerID": "ollama", "modelID": "llama3.2"},
|
||||
},
|
||||
}
|
||||
data, _ := json.MarshalIndent(state, "", " ")
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
content := o.resolveContent("gemma4", []LaunchModel{
|
||||
{
|
||||
Name: "gemma4",
|
||||
Capabilities: []model.Capability{model.CapabilityVision},
|
||||
ContextLength: 65_536,
|
||||
MaxOutputTokens: 8_192,
|
||||
},
|
||||
})
|
||||
if content == "" {
|
||||
t.Fatal("resolveContent returned empty")
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(content), &cfg)
|
||||
provider, _ := cfg["provider"].(map[string]any)
|
||||
ollama, _ := provider["ollama"].(map[string]any)
|
||||
cfgModels, _ := ollama["models"].(map[string]any)
|
||||
entry, _ := cfgModels["gemma4"].(map[string]any)
|
||||
limit, _ := entry["limit"].(map[string]any)
|
||||
if limit["context"] != float64(65_536) || limit["output"] != float64(8_192) {
|
||||
t.Fatalf("limit = %v, want context/output from launch metadata", limit)
|
||||
}
|
||||
if _, ok := entry["modalities"].(map[string]any); !ok {
|
||||
t.Fatalf("modalities should be set from launch metadata, got %v", entry["modalities"])
|
||||
}
|
||||
if cfgModels["llama3.2"] == nil {
|
||||
t.Fatalf("state model missing from fallback config: %v", cfgModels)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not mutate configContent on fallback", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -542,7 +626,7 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
_ = o.resolveContent("llama3.2")
|
||||
_ = o.resolveContent("llama3.2", nil)
|
||||
if o.configContent != "" {
|
||||
t.Errorf("resolveContent should not mutate configContent, got %q", o.configContent)
|
||||
}
|
||||
@@ -551,19 +635,19 @@ func TestOpenCodeResolveContent(t *testing.T) {
|
||||
|
||||
func TestBuildInlineConfig(t *testing.T) {
|
||||
t.Run("returns error for empty primary", func(t *testing.T) {
|
||||
if _, err := buildInlineConfig("", []string{"llama3.2"}); err == nil {
|
||||
if _, err := buildInlineConfig(LaunchModel{}, testLaunchModels("llama3.2")); err == nil {
|
||||
t.Error("expected error for empty primary")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error for empty models", func(t *testing.T) {
|
||||
if _, err := buildInlineConfig("llama3.2", nil); err == nil {
|
||||
if _, err := buildInlineConfig(fallbackLaunchModel("llama3.2"), nil); err == nil {
|
||||
t.Error("expected error for empty models")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("primary differs from first model in list", func(t *testing.T) {
|
||||
content, err := buildInlineConfig("qwen3:32b", []string{"llama3.2", "qwen3:32b"})
|
||||
content, err := buildInlineConfig(fallbackLaunchModel("qwen3:32b"), testLaunchModels("llama3.2", "qwen3:32b"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -592,7 +676,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"new-X"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("new-X")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -626,7 +710,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"X", "Y", "Z"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("X", "Y", "Z")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -663,7 +747,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"qwen3:32b"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("qwen3:32b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -700,7 +784,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(stateDir, "model.json"), data, 0o644)
|
||||
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -742,7 +826,7 @@ func TestOpenCodeEdit_PreservesRecentEntries(t *testing.T) {
|
||||
|
||||
// Add 5 new models — should cap at 10 total
|
||||
o := &OpenCode{}
|
||||
if err := o.Edit([]string{"new-0", "new-1", "new-2", "new-3", "new-4"}); err != nil {
|
||||
if err := o.Edit(testLaunchModels("new-0", "new-1", "new-2", "new-3", "new-4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -763,7 +847,7 @@ func TestOpenCodeEdit_BaseURL(t *testing.T) {
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
// Default OLLAMA_HOST
|
||||
o.Edit([]string{"llama3.2"})
|
||||
o.Edit(testLaunchModels("llama3.2"))
|
||||
|
||||
var cfg map[string]any
|
||||
json.Unmarshal([]byte(o.configContent), &cfg)
|
||||
|
||||
+13
-44
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -14,7 +13,6 @@ import (
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
|
||||
@@ -28,7 +26,7 @@ const (
|
||||
|
||||
func (p *Pi) String() string { return "Pi" }
|
||||
|
||||
func (p *Pi) Run(model string, args []string) error {
|
||||
func (p *Pi) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := ensureNpmInstalled(); err != nil {
|
||||
return err
|
||||
@@ -183,7 +181,7 @@ func (p *Pi) Paths() []string {
|
||||
return paths
|
||||
}
|
||||
|
||||
func (p *Pi) Edit(models []string) error {
|
||||
func (p *Pi) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -225,7 +223,7 @@ func (p *Pi) Edit(models []string) error {
|
||||
// Build set of selected models to track which need to be added
|
||||
selectedSet := make(map[string]bool, len(models))
|
||||
for _, m := range models {
|
||||
selectedSet[m] = true
|
||||
selectedSet[m.Name] = true
|
||||
}
|
||||
|
||||
// Build new models list:
|
||||
@@ -256,11 +254,9 @@ func (p *Pi) Edit(models []string) error {
|
||||
}
|
||||
|
||||
// Add newly selected models that weren't already in the list
|
||||
client := api.NewClient(envconfig.Host(), http.DefaultClient)
|
||||
ctx := context.Background()
|
||||
for _, model := range models {
|
||||
if selectedSet[model] {
|
||||
newModels = append(newModels, createConfig(ctx, client, model))
|
||||
if selectedSet[model.Name] {
|
||||
newModels = append(newModels, createConfig(model))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,7 +280,7 @@ func (p *Pi) Edit(models []string) error {
|
||||
}
|
||||
|
||||
settings["defaultProvider"] = "ollama"
|
||||
settings["defaultModel"] = models[0]
|
||||
settings["defaultModel"] = models[0].Name
|
||||
|
||||
settingsData, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
@@ -342,54 +338,27 @@ func hasContextWindow(cfg map[string]any) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// createConfig builds Pi model config with capability detection
|
||||
func createConfig(ctx context.Context, client *api.Client, modelID string) map[string]any {
|
||||
// createConfig builds Pi model config with capability detection.
|
||||
func createConfig(model LaunchModel) map[string]any {
|
||||
cfg := map[string]any{
|
||||
"id": modelID,
|
||||
"id": model.Name,
|
||||
"_launch": true,
|
||||
}
|
||||
if l, ok := lookupCloudModelLimit(modelID); ok {
|
||||
cfg["contextWindow"] = l.Context
|
||||
}
|
||||
|
||||
applyCloudContextFallback := func() {
|
||||
if l, ok := lookupCloudModelLimit(modelID); ok {
|
||||
cfg["contextWindow"] = l.Context
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Model: modelID})
|
||||
if err != nil {
|
||||
applyCloudContextFallback()
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Set input types based on vision capability
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityVision) {
|
||||
if model.HasCapability("vision") {
|
||||
cfg["input"] = []string{"text", "image"}
|
||||
} else {
|
||||
cfg["input"] = []string{"text"}
|
||||
}
|
||||
|
||||
// Set reasoning based on thinking capability
|
||||
if slices.Contains(resp.Capabilities, model.CapabilityThinking) {
|
||||
if model.HasCapability("thinking") {
|
||||
cfg["reasoning"] = true
|
||||
}
|
||||
|
||||
// Extract context window from ModelInfo. For known cloud models, the
|
||||
// pre-filled shared limit remains unless the server provides a positive value.
|
||||
hasContextWindow := false
|
||||
for key, val := range resp.ModelInfo {
|
||||
if strings.HasSuffix(key, ".context_length") {
|
||||
if ctxLen, ok := val.(float64); ok && ctxLen > 0 {
|
||||
cfg["contextWindow"] = int(ctxLen)
|
||||
hasContextWindow = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasContextWindow {
|
||||
applyCloudContextFallback()
|
||||
if model.ContextLength > 0 {
|
||||
cfg["contextWindow"] = model.ContextLength
|
||||
}
|
||||
|
||||
return cfg
|
||||
|
||||
+44
-153
@@ -1,19 +1,16 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
@@ -138,7 +135,7 @@ exit 0
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", []string{"--version"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"--version"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -181,7 +178,7 @@ exit 0
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
err := p.Run("ignored", nil)
|
||||
err := p.Run("ignored", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "pi installation cancelled") {
|
||||
t.Fatalf("expected install cancellation error, got %v", err)
|
||||
}
|
||||
@@ -203,7 +200,7 @@ exit 0
|
||||
})
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -238,7 +235,7 @@ exit 0
|
||||
seedNpmNoop(t, tmpDir)
|
||||
|
||||
p := &Pi{}
|
||||
if err := p.Run("ignored", []string{"doctor"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"doctor"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -266,7 +263,7 @@ exit 0
|
||||
|
||||
p := &Pi{}
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() should continue after web search update failure, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -301,7 +298,7 @@ exit 0
|
||||
|
||||
p := &Pi{}
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() should continue after web search install failure, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -331,7 +328,7 @@ exit 0
|
||||
|
||||
p := &Pi{}
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := p.Run("ignored", []string{"session"}); err != nil {
|
||||
if err := p.Run("ignored", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -360,7 +357,7 @@ exit 0
|
||||
seedPiScript(t, tmpDir)
|
||||
|
||||
p := &Pi{}
|
||||
err := p.Run("ignored", []string{"session"})
|
||||
err := p.Run("ignored", nil, []string{"session"})
|
||||
if err == nil || !strings.Contains(err.Error(), "npm (Node.js) is required to launch pi") {
|
||||
t.Fatalf("expected missing npm error, got %v", err)
|
||||
}
|
||||
@@ -435,7 +432,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
t.Run("returns nil for empty models", func(t *testing.T) {
|
||||
if err := pi.Edit([]string{}); err != nil {
|
||||
if err := pi.Edit(testLaunchModels()); err != nil {
|
||||
t.Errorf("Edit([]) error = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
@@ -444,7 +441,7 @@ func TestPiEdit(t *testing.T) {
|
||||
cleanup()
|
||||
|
||||
models := []string{"llama3.2", "qwen3:8b"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -497,7 +494,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
models := []string{"new-model"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -550,7 +547,7 @@ func TestPiEdit(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pi.Edit([]string{"glm-5:cloud"}); err != nil {
|
||||
if err := pi.Edit(testLaunchModels("glm-5:cloud")); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -595,7 +592,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
newModels := []string{"new-model-1", "new-model-2"}
|
||||
if err := pi.Edit(newModels); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -646,7 +643,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
newModels := []string{"keep-model", "add-model"}
|
||||
if err := pi.Edit(newModels); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -683,7 +680,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
models := []string{"test-model"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() should not fail with corrupt config, got %v", err)
|
||||
}
|
||||
|
||||
@@ -732,7 +729,7 @@ func TestPiEdit(t *testing.T) {
|
||||
|
||||
// Add a new ollama-managed model
|
||||
newModels := []string{"new-ollama-model"}
|
||||
if err := pi.Edit(newModels); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(newModels)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -793,7 +790,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
models := []string{"llama3.2"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -831,7 +828,7 @@ func TestPiEdit(t *testing.T) {
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
|
||||
models := []string{"qwen3:8b"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -865,7 +862,7 @@ func TestPiEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
models := []string{"test-model"}
|
||||
if err := pi.Edit(models); err != nil {
|
||||
if err := pi.Edit(launchModelsFromNames(models)); err != nil {
|
||||
t.Fatalf("Edit() should not fail with corrupt settings, got %v", err)
|
||||
}
|
||||
|
||||
@@ -921,7 +918,7 @@ func TestPiEdit_CreatesDistinctBackupsForEachManagedFile(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pi.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := pi.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -1087,19 +1084,7 @@ func TestIsPiOllamaModel(t *testing.T) {
|
||||
|
||||
func TestCreateConfig(t *testing.T) {
|
||||
t.Run("sets vision input when model has vision capability", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "llava:7b")
|
||||
cfg := createConfig(LaunchModel{Name: "llava:7b", Capabilities: []model.Capability{model.CapabilityVision}})
|
||||
|
||||
if cfg["id"] != "llava:7b" {
|
||||
t.Errorf("id = %v, want llava:7b", cfg["id"])
|
||||
@@ -1114,19 +1099,7 @@ func TestCreateConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets text-only input when model lacks vision", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["completion"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "llama3.2")
|
||||
cfg := createConfig(LaunchModel{Name: "llama3.2", Capabilities: []model.Capability{model.CapabilityCompletion}})
|
||||
|
||||
input, ok := cfg["input"].([]string)
|
||||
if !ok || len(input) != 1 || input[0] != "text" {
|
||||
@@ -1138,39 +1111,15 @@ func TestCreateConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("sets reasoning when model has thinking capability", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["thinking"],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "qwq")
|
||||
cfg := createConfig(LaunchModel{Name: "qwq", Capabilities: []model.Capability{model.CapabilityThinking}})
|
||||
|
||||
if cfg["reasoning"] != true {
|
||||
t.Error("expected reasoning = true for thinking model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("extracts context window from model info", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":131072}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "llama3.2")
|
||||
t.Run("sets context window from metadata", func(t *testing.T) {
|
||||
cfg := createConfig(LaunchModel{Name: "llama3.2", ContextLength: 131072})
|
||||
|
||||
if cfg["contextWindow"] != 131072 {
|
||||
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
|
||||
@@ -1178,19 +1127,11 @@ func TestCreateConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("handles all capabilities together", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":["vision","thinking"],"model_info":{"qwen3.context_length":32768}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "qwen3-vision")
|
||||
cfg := createConfig(LaunchModel{
|
||||
Name: "qwen3-vision",
|
||||
Capabilities: []model.Capability{model.CapabilityVision, model.CapabilityThinking},
|
||||
ContextLength: 32768,
|
||||
})
|
||||
|
||||
input := cfg["input"].([]string)
|
||||
if len(input) != 2 || input[0] != "text" || input[1] != "image" {
|
||||
@@ -1204,17 +1145,8 @@ func TestCreateConfig(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns minimal config when show fails", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"model not found"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "missing-model")
|
||||
t.Run("returns minimal config when metadata is unavailable", func(t *testing.T) {
|
||||
cfg := createConfig(LaunchModel{Name: "missing-model"})
|
||||
|
||||
if cfg["id"] != "missing-model" {
|
||||
t.Errorf("id = %v, want missing-model", cfg["id"])
|
||||
@@ -1222,49 +1154,29 @@ func TestCreateConfig(t *testing.T) {
|
||||
if cfg["_launch"] != true {
|
||||
t.Error("expected _launch = true")
|
||||
}
|
||||
// Should not have capability fields
|
||||
if _, ok := cfg["input"]; ok {
|
||||
t.Error("input should not be set when show fails")
|
||||
// Input defaults to text even when capabilities are unavailable.
|
||||
input, ok := cfg["input"].([]string)
|
||||
if !ok || len(input) != 1 || input[0] != "text" {
|
||||
t.Errorf("input = %v, want [text]", cfg["input"])
|
||||
}
|
||||
if _, ok := cfg["reasoning"]; ok {
|
||||
t.Error("reasoning should not be set when show fails")
|
||||
t.Error("reasoning should not be set when metadata is unavailable")
|
||||
}
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set when show fails")
|
||||
t.Error("contextWindow should not be set when metadata is unavailable")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cloud model falls back to hardcoded context when show fails", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"model not found"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "kimi-k2.5:cloud")
|
||||
t.Run("cloud model falls back to hardcoded context", func(t *testing.T) {
|
||||
cfg := createConfig(fallbackLaunchModel("kimi-k2.5:cloud"))
|
||||
|
||||
if cfg["contextWindow"] != 262_144 {
|
||||
t.Errorf("contextWindow = %v, want 262144", cfg["contextWindow"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cloud model falls back to hardcoded context when show omits model info", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "glm-5:cloud")
|
||||
t.Run("cloud model uses hardcoded context when tags omit context", func(t *testing.T) {
|
||||
cfg := createConfig(fallbackLaunchModel("glm-5:cloud"))
|
||||
|
||||
if cfg["contextWindow"] != 202_752 {
|
||||
t.Errorf("contextWindow = %v, want 202752", cfg["contextWindow"])
|
||||
@@ -1272,35 +1184,14 @@ func TestCreateConfig(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("cloud model with dash suffix falls back to hardcoded context", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"model not found"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "gpt-oss:120b-cloud")
|
||||
cfg := createConfig(fallbackLaunchModel("gpt-oss:120b-cloud"))
|
||||
|
||||
if cfg["contextWindow"] != 131_072 {
|
||||
t.Errorf("contextWindow = %v, want 131072", cfg["contextWindow"])
|
||||
}
|
||||
})
|
||||
t.Run("skips zero context length", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprintf(w, `{"capabilities":[],"model_info":{"llama.context_length":0}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
cfg := createConfig(context.Background(), client, "test-model")
|
||||
cfg := createConfig(LaunchModel{Name: "test-model", ContextLength: 0})
|
||||
|
||||
if _, ok := cfg["contextWindow"]; ok {
|
||||
t.Error("contextWindow should not be set for zero value")
|
||||
|
||||
@@ -29,7 +29,7 @@ func (p *Poolside) args(model string, extra []string) []string {
|
||||
return args
|
||||
}
|
||||
|
||||
func (p *Poolside) Run(model string, args []string) error {
|
||||
func (p *Poolside) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if poolsideGOOS == "windows" {
|
||||
return poolsideUnsupportedError()
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestPoolsideRunSetsOllamaEnv(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||
|
||||
p := &Poolside{}
|
||||
if err := p.Run("qwen3.5", []string{"session"}); err != nil {
|
||||
if err := p.Run("qwen3.5", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func TestPoolsideRunWindowsUnsupported(t *testing.T) {
|
||||
t.Cleanup(func() { poolsideGOOS = prev })
|
||||
|
||||
p := &Poolside{}
|
||||
err := p.Run("kimi-k2.6:cloud", nil)
|
||||
err := p.Run("kimi-k2.6:cloud", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected Windows unsupported error")
|
||||
}
|
||||
|
||||
+13
-1
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
var launcherIntegrationOrder = []string{"claude", "openclaw", "hermes", "opencode", "codex", "copilot", "droid", "pi", "pool"}
|
||||
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "droid", "pi", "pool"}
|
||||
|
||||
var integrationSpecs = []*IntegrationSpec{
|
||||
{
|
||||
@@ -87,6 +87,18 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Command: []string{"npm", "install", "-g", "@openai/codex"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "codex-app",
|
||||
Runner: &CodexApp{},
|
||||
Aliases: []string{"codex-desktop", "codex-gui"},
|
||||
Description: "An AI agent you can delegate real work to, by OpenAI",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
return codexAppInstalled()
|
||||
},
|
||||
URL: "https://developers.openai.com/codex/quickstart",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "kimi",
|
||||
Runner: &Kimi{},
|
||||
|
||||
@@ -84,7 +84,7 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
configPath := tt.checkPath(home)
|
||||
if err := tt.runner.Run("llama3.2", nil); err != nil {
|
||||
if err := tt.runner.Run("llama3.2", nil, nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
|
||||
|
||||
@@ -41,6 +41,10 @@ func setTestHome(t *testing.T, dir string) {
|
||||
setLaunchTestHome(t, dir)
|
||||
}
|
||||
|
||||
func testLaunchModels(names ...string) []LaunchModel {
|
||||
return launchModelsFromNames(names)
|
||||
}
|
||||
|
||||
func SaveIntegration(appName string, models []string) error {
|
||||
return config.SaveIntegration(appName, models)
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ const (
|
||||
minVSCodeVersion = "1.113"
|
||||
)
|
||||
|
||||
func (v *VSCode) Run(model string, args []string) error {
|
||||
func (v *VSCode) Run(model string, _ []LaunchModel, args []string) error {
|
||||
v.checkVSCodeVersion()
|
||||
v.checkCopilotChatVersion()
|
||||
|
||||
@@ -238,7 +238,7 @@ func (v *VSCode) Paths() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VSCode) Edit(models []string) error {
|
||||
func (v *VSCode) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestVSCodeEdit(t *testing.T) {
|
||||
os.WriteFile(clmPath, []byte(tt.setup), 0o644)
|
||||
}
|
||||
|
||||
if err := v.Edit(tt.models); err != nil {
|
||||
if err := v.Edit(launchModelsFromNames(tt.models)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
|
||||
os.MkdirAll(filepath.Dir(settingsPath), 0o755)
|
||||
os.WriteFile(settingsPath, []byte(`{"github.copilot.chat.byok.ollamaEndpoint": "http://old:11434", "ollama.launch.configured": true, "editor.fontSize": 14}`), 0o644)
|
||||
|
||||
if err := v.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ func TestVSCodeEdit_CreatesDistinctBackupsForManagedFiles(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := v.Edit([]string{"llama3.2"}); err != nil {
|
||||
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
+21
-1
@@ -29,6 +29,13 @@ func launcherTestState() *launch.LauncherState {
|
||||
Selectable: true,
|
||||
Changeable: true,
|
||||
},
|
||||
"codex-app": {
|
||||
Name: "codex-app",
|
||||
DisplayName: "Codex App",
|
||||
Description: "An AI agent you can delegate real work to, by OpenAI",
|
||||
Selectable: true,
|
||||
Changeable: true,
|
||||
},
|
||||
"openclaw": {
|
||||
Name: "openclaw",
|
||||
DisplayName: "OpenClaw",
|
||||
@@ -122,12 +129,25 @@ func expectedExpandedSequence(state *launch.LauncherState) []string {
|
||||
func TestMenuRendersPinnedItemsAndMore(t *testing.T) {
|
||||
state := launcherTestState()
|
||||
menu := newModel(state)
|
||||
wantPrefix := []string{"run", "claude", "codex-app", "hermes", "openclaw"}
|
||||
if findMenuCursorByIntegration(menu.items, "codex-app") == -1 {
|
||||
wantPrefix = []string{"run", "claude", "hermes", "openclaw", "opencode"}
|
||||
}
|
||||
if got := integrationSequence(menu.items); len(got) < len(wantPrefix) {
|
||||
t.Fatalf("expected at least %d menu items, got %v", len(wantPrefix), got)
|
||||
} else if diff := compareStrings(got[:len(wantPrefix)], wantPrefix); diff != "" {
|
||||
t.Fatalf("unexpected primary TUI order: %s", diff)
|
||||
}
|
||||
|
||||
view := menu.View()
|
||||
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch OpenClaw", "Launch Hermes Agent", "More..."} {
|
||||
for _, want := range []string{"Chat with a model", "Launch Claude Code", "Launch Hermes Agent", "Launch OpenClaw", "More..."} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Fatalf("expected menu view to contain %q\n%s", want, view)
|
||||
}
|
||||
}
|
||||
if findMenuCursorByIntegration(menu.items, "codex-app") != -1 && !strings.Contains(view, "Launch Codex App") {
|
||||
t.Fatalf("expected menu view to contain Codex App\n%s", view)
|
||||
}
|
||||
if strings.Contains(view, "Launch Claude Desktop") {
|
||||
t.Fatalf("expected hidden Claude Desktop to be absent\n%s", view)
|
||||
}
|
||||
|
||||
+72
-3
@@ -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,6 +559,378 @@ func (q *qwen3NextModel) KV(t *Tokenizer) KV {
|
||||
return kv
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) TextKV(t *Tokenizer) KV {
|
||||
kv := q.KV(t)
|
||||
|
||||
for _, key := range []string{
|
||||
"vision.block_count",
|
||||
"vision.embedding_length",
|
||||
"vision.feed_forward_length",
|
||||
"vision.attention.head_count",
|
||||
"vision.num_channels",
|
||||
"vision.patch_size",
|
||||
"vision.spatial_merge_size",
|
||||
"vision.attention.layer_norm_epsilon",
|
||||
"vision.rope.freq_base",
|
||||
"vision.temporal_patch_size",
|
||||
"vision.deepstack_visual_indexes",
|
||||
"vision.shortest_edge",
|
||||
"vision.longest_edge",
|
||||
"vision.image_mean",
|
||||
"vision.image_std",
|
||||
"image_token_id",
|
||||
"vision_start_token_id",
|
||||
"vision_end_token_id",
|
||||
"mrope_sections",
|
||||
"rope.mrope_section",
|
||||
"rope.mrope_interleaved",
|
||||
"ssm.v_head_reordered",
|
||||
} {
|
||||
delete(kv, key)
|
||||
}
|
||||
|
||||
return kv
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) ProjectorKV(*Tokenizer) KV {
|
||||
depth := q.VisionModel.Depth
|
||||
deepstack := make([]bool, depth)
|
||||
for _, idx := range q.VisionModel.DeepstackVisualIndexes {
|
||||
if idx >= 0 && uint32(idx) < depth {
|
||||
deepstack[idx] = true
|
||||
}
|
||||
}
|
||||
|
||||
imageSize := uint32(768)
|
||||
if q.VisionModel.NumPositionEmbeddings > 0 && q.VisionModel.PatchSize > 0 {
|
||||
root := uint32(math.Sqrt(float64(q.VisionModel.NumPositionEmbeddings)))
|
||||
if root*root == q.VisionModel.NumPositionEmbeddings {
|
||||
imageSize = root * q.VisionModel.PatchSize
|
||||
}
|
||||
}
|
||||
|
||||
projectionDim := q.VisionModel.OutHiddenSize
|
||||
if projectionDim == 0 {
|
||||
projectionDim = q.HiddenSize
|
||||
}
|
||||
layerNormEps := q.VisionModel.RMSNormEps
|
||||
if layerNormEps == 0 {
|
||||
layerNormEps = 1e-6
|
||||
}
|
||||
|
||||
kv := KV{
|
||||
"general.architecture": "clip",
|
||||
"general.type": "mmproj",
|
||||
"general.file_type": uint32(1),
|
||||
"general.quantization_version": uint32(2),
|
||||
"clip.has_vision_encoder": true,
|
||||
"clip.projector_type": "qwen3vl_merger",
|
||||
"clip.use_gelu": true,
|
||||
"clip.vision.block_count": depth,
|
||||
"clip.vision.embedding_length": q.VisionModel.HiddenSize,
|
||||
"clip.vision.feed_forward_length": q.VisionModel.IntermediateSize,
|
||||
"clip.vision.attention.head_count": q.VisionModel.NumHeads,
|
||||
"clip.vision.image_size": imageSize,
|
||||
"clip.vision.patch_size": q.VisionModel.PatchSize,
|
||||
"clip.vision.projection_dim": projectionDim,
|
||||
"clip.vision.spatial_merge_size": q.VisionModel.SpatialMergeSize,
|
||||
"clip.vision.attention.layer_norm_epsilon": layerNormEps,
|
||||
"clip.vision.is_deepstack_layers": deepstack,
|
||||
}
|
||||
if len(q.VisionModel.ImageMean) > 0 {
|
||||
kv["clip.vision.image_mean"] = q.VisionModel.ImageMean
|
||||
}
|
||||
if len(q.VisionModel.ImageStd) > 0 {
|
||||
kv["clip.vision.image_std"] = q.VisionModel.ImageStd
|
||||
}
|
||||
|
||||
return kv
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) TextTensors(ts []Tensor, _ *Tokenizer) []*ggml.Tensor {
|
||||
var text []Tensor
|
||||
for _, t := range ts {
|
||||
if qwen3NextVisionTensor(t.Name()) {
|
||||
continue
|
||||
}
|
||||
text = append(text, t)
|
||||
}
|
||||
|
||||
return q.Tensors(text)
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) ProjectorTensors(ts []Tensor) []*ggml.Tensor {
|
||||
if q.VisionModel.Depth == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
rename := strings.NewReplacer(
|
||||
"v.pos_embed", "v.position_embd",
|
||||
"v.patch_embed", "v.patch_embd",
|
||||
"v.merger.norm", "v.post_ln",
|
||||
"v.merger.linear_fc1", "mm.0",
|
||||
"v.merger.linear_fc2", "mm.2",
|
||||
".mlp.linear_fc1", ".ffn_up",
|
||||
".mlp.linear_fc2", ".ffn_down",
|
||||
".norm1", ".ln1",
|
||||
".norm2", ".ln2",
|
||||
)
|
||||
|
||||
var out []*ggml.Tensor
|
||||
for _, t := range ts {
|
||||
name := t.Name()
|
||||
if !qwen3NextVisionTensor(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
if name == "v.patch_embed.weight" {
|
||||
out = append(out, q.qwen35PatchEmbedTensors(t)...)
|
||||
continue
|
||||
}
|
||||
|
||||
outName := rename.Replace(name)
|
||||
kind := t.Kind()
|
||||
writer := io.WriterTo(t)
|
||||
if outName == "v.position_embd.weight" {
|
||||
kind = tensorKindFP32
|
||||
writer = tensorFloat32Writer{tensor: t}
|
||||
} else if sourceDType(t) == "BF16" && kind == tensorKindFP16 {
|
||||
kind = tensorKindBF16
|
||||
writer = tensorBF16Writer{tensor: t}
|
||||
}
|
||||
out = append(out, &ggml.Tensor{
|
||||
Name: outName,
|
||||
Kind: kind,
|
||||
Shape: slices.Clone(t.Shape()),
|
||||
WriterTo: writer,
|
||||
})
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func qwen3NextVisionTensor(name string) bool {
|
||||
return strings.HasPrefix(name, "v.")
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) qwen35PatchEmbedTensors(t Tensor) []*ggml.Tensor {
|
||||
shape := t.Shape()
|
||||
if len(shape) != 5 || shape[2] != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
outShape := []uint64{shape[0], shape[1], shape[3], shape[4]}
|
||||
return []*ggml.Tensor{
|
||||
{
|
||||
Name: "v.patch_embd.weight",
|
||||
Kind: tensorKindFP32,
|
||||
Shape: slices.Clone(outShape),
|
||||
WriterTo: tensorFloat32Writer{tensor: t, repacker: q.qwen35PatchEmbedSlice(0)},
|
||||
},
|
||||
{
|
||||
Name: "v.patch_embd.weight.1",
|
||||
Kind: tensorKindFP32,
|
||||
Shape: slices.Clone(outShape),
|
||||
WriterTo: tensorFloat32Writer{tensor: t, repacker: q.qwen35PatchEmbedSlice(1)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) qwen35PatchEmbedSlice(slice int) Repacker {
|
||||
return func(_ string, data []float32, shape []uint64) ([]float32, error) {
|
||||
if len(shape) != 5 || shape[2] != 2 {
|
||||
return nil, fmt.Errorf("qwen3next: unexpected patch_embed shape %v", shape)
|
||||
}
|
||||
|
||||
outChannels := int(shape[0])
|
||||
inChannels := int(shape[1])
|
||||
frames := int(shape[2])
|
||||
height := int(shape[3])
|
||||
width := int(shape[4])
|
||||
if slice < 0 || slice >= frames {
|
||||
return nil, fmt.Errorf("qwen3next: patch_embed slice %d out of range", slice)
|
||||
}
|
||||
|
||||
expected := outChannels * inChannels * frames * height * width
|
||||
if len(data) != expected {
|
||||
return nil, fmt.Errorf("qwen3next: patch_embed data size %d, expected %d", len(data), expected)
|
||||
}
|
||||
|
||||
out := make([]float32, outChannels*inChannels*height*width)
|
||||
for oc := range outChannels {
|
||||
for ic := range inChannels {
|
||||
for y := range height {
|
||||
for x := range width {
|
||||
src := ((((oc*inChannels+ic)*frames+slice)*height + y) * width) + x
|
||||
dst := (((oc*inChannels+ic)*height + y) * width) + x
|
||||
out[dst] = data[src]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
type tensorBF16Writer struct {
|
||||
tensor Tensor
|
||||
repacker Repacker
|
||||
}
|
||||
|
||||
func (w tensorBF16Writer) WriteTo(dst io.Writer) (int64, error) {
|
||||
data, err := tensorFloat32Data(w.tensor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if w.repacker != nil {
|
||||
data, err = w.repacker(w.tensor.Name(), data, w.tensor.Shape())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
u8s := bfloat16.EncodeFloat32(data)
|
||||
if _, err := dst.Write(u8s); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(u8s)), nil
|
||||
}
|
||||
|
||||
type tensorFloat32Writer struct {
|
||||
tensor Tensor
|
||||
repacker Repacker
|
||||
}
|
||||
|
||||
func (w tensorFloat32Writer) WriteTo(dst io.Writer) (int64, error) {
|
||||
data, err := tensorFloat32Data(w.tensor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if w.repacker != nil {
|
||||
data, err = w.repacker(w.tensor.Name(), data, w.tensor.Shape())
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := binary.Write(dst, binary.LittleEndian, data); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int64(len(data) * 4), nil
|
||||
}
|
||||
|
||||
func tensorFloat32Data(t Tensor) ([]float32, error) {
|
||||
if st, ok := tensorSafetensor(t); ok {
|
||||
return safetensorFloat32Data(st)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if _, err := t.WriteTo(&buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch t.Kind() {
|
||||
case tensorKindFP32:
|
||||
out := make([]float32, buf.Len()/4)
|
||||
if err := binary.Read(bytes.NewReader(buf.Bytes()), binary.LittleEndian, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
case tensorKindFP16:
|
||||
raw := make([]uint16, buf.Len()/2)
|
||||
if err := binary.Read(bytes.NewReader(buf.Bytes()), binary.LittleEndian, raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]float32, len(raw))
|
||||
for i, v := range raw {
|
||||
out[i] = float16.Frombits(v).Float32()
|
||||
}
|
||||
return out, nil
|
||||
case tensorKindBF16:
|
||||
return bfloat16.DecodeFloat32(buf.Bytes()), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported tensor kind %d for F32 writer", t.Kind())
|
||||
}
|
||||
}
|
||||
|
||||
func tensorSafetensor(t Tensor) (safetensor, bool) {
|
||||
switch t := t.(type) {
|
||||
case safetensor:
|
||||
return t, true
|
||||
case *safetensor:
|
||||
return *t, true
|
||||
default:
|
||||
return safetensor{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func safetensorFloat32Data(st safetensor) ([]float32, error) {
|
||||
f, err := st.fs.Open(st.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var r io.Reader
|
||||
if readerAt, ok := f.(io.ReaderAt); ok {
|
||||
r = io.NewSectionReader(readerAt, st.offset, st.size)
|
||||
} else if seeker, ok := f.(io.Seeker); ok {
|
||||
if _, err := seeker.Seek(st.offset, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r = f
|
||||
} else {
|
||||
if _, err := io.CopyN(io.Discard, f, st.offset); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r = f
|
||||
}
|
||||
|
||||
br := bufio.NewReaderSize(r, min(32<<10, int(st.size)))
|
||||
var out []float32
|
||||
switch st.dtype {
|
||||
case "F32":
|
||||
out = make([]float32, st.size/4)
|
||||
if err := binary.Read(br, binary.LittleEndian, out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case "F16":
|
||||
raw := make([]uint16, st.size/2)
|
||||
if err := binary.Read(br, binary.LittleEndian, raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = make([]float32, len(raw))
|
||||
for i, v := range raw {
|
||||
out[i] = float16.Frombits(v).Float32()
|
||||
}
|
||||
case "BF16":
|
||||
raw := make([]uint8, st.size)
|
||||
if err := binary.Read(br, binary.LittleEndian, raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = bfloat16.DecodeFloat32(raw)
|
||||
case "F8_E4M3":
|
||||
raw := make([]uint8, st.size)
|
||||
if err := binary.Read(br, binary.LittleEndian, raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err = st.decodeFP8E4M3(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported safetensor dtype %q", st.dtype)
|
||||
}
|
||||
|
||||
if st.repacker != nil {
|
||||
out, err = st.repacker(st.Name(), out, st.Shape())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) Tensors(ts []Tensor) []*ggml.Tensor {
|
||||
var out []*ggml.Tensor
|
||||
|
||||
@@ -398,6 +957,13 @@ func (q *qwen3NextModel) Tensors(ts []Tensor) []*ggml.Tensor {
|
||||
name := t.Name()
|
||||
shape := t.Shape()
|
||||
|
||||
if names := q.mtpTensorNames(name); len(names) > 0 {
|
||||
for _, name := range names {
|
||||
out = q.appendDirectTensor(out, t, name)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(name, ".ssm_in.weight") {
|
||||
if qkv, gate, ok := q.splitQKVZTensor(t); ok {
|
||||
out = append(out, qkv, gate)
|
||||
@@ -464,7 +1030,7 @@ func (q *qwen3NextModel) Tensors(ts []Tensor) []*ggml.Tensor {
|
||||
}
|
||||
out = append(out, &ggml.Tensor{Name: name, Kind: t.Kind(), Shape: slices.Clone(shape), WriterTo: t})
|
||||
|
||||
case strings.HasSuffix(name, ".ssm_dt"):
|
||||
case strings.HasSuffix(name, ".ssm_dt"), strings.HasSuffix(name, ".ssm_dt.bias"):
|
||||
if q.shouldReorderVHeads() {
|
||||
t.SetRepacker(q.repackReorderDim(0, 1))
|
||||
}
|
||||
@@ -499,6 +1065,73 @@ func (q *qwen3NextModel) Tensors(ts []Tensor) []*ggml.Tensor {
|
||||
return out
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) appendDirectTensor(out []*ggml.Tensor, t Tensor, name string) []*ggml.Tensor {
|
||||
if qwen3NextShouldShiftNorm(name) {
|
||||
t = t.Clone()
|
||||
t.SetRepacker(q.addOne)
|
||||
}
|
||||
return append(out, &ggml.Tensor{Name: name, Kind: t.Kind(), Shape: slices.Clone(t.Shape()), WriterTo: t})
|
||||
}
|
||||
|
||||
func qwen3NextShouldShiftNorm(name string) bool {
|
||||
if strings.HasSuffix(name, ".ssm_norm.weight") {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(name, "_norm.weight") ||
|
||||
strings.HasSuffix(name, ".nextn.enorm.weight") ||
|
||||
strings.HasSuffix(name, ".nextn.hnorm.weight")
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) mtpTensorNames(name string) []string {
|
||||
if !strings.HasPrefix(name, "mtp.") {
|
||||
return nil
|
||||
}
|
||||
|
||||
base := q.NumHiddenLayers
|
||||
nextn := q.NumNextNPredictLayers
|
||||
if nextn == 0 {
|
||||
nextn = 1
|
||||
}
|
||||
|
||||
if rest := strings.TrimPrefix(name, "mtp.layers."); rest != name {
|
||||
layer, suffix, ok := strings.Cut(rest, ".")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
idx, err := strconv.ParseUint(layer, 10, 32)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{fmt.Sprintf("blk.%d.%s", base+uint32(idx), suffix)}
|
||||
}
|
||||
|
||||
var suffix string
|
||||
switch name {
|
||||
case "mtp.fc.weight":
|
||||
suffix = "nextn.eh_proj.weight"
|
||||
case "mtp.pre_fc_norm_embedding.weight":
|
||||
suffix = "nextn.enorm.weight"
|
||||
case "mtp.pre_fc_norm_hidden.weight":
|
||||
suffix = "nextn.hnorm.weight"
|
||||
case "mtp.norm.weight":
|
||||
suffix = "nextn.shared_head_norm.weight"
|
||||
case "mtp.embed_tokens.weight":
|
||||
suffix = "nextn.embed_tokens.weight"
|
||||
case "mtp.shared_head.head.weight":
|
||||
suffix = "nextn.shared_head_head.weight"
|
||||
case "mtp.shared_head.norm.weight":
|
||||
suffix = "nextn.shared_head_norm.weight"
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
names := make([]string, 0, nextn)
|
||||
for i := range nextn {
|
||||
names = append(names, fmt.Sprintf("blk.%d.%s", base+i, suffix))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (q *qwen3NextModel) repackReorderDim(dim, headDim int) Repacker {
|
||||
return func(_ string, data []float32, shape []uint64) ([]float32, error) {
|
||||
if !q.shouldReorderVHeads() {
|
||||
@@ -925,7 +1558,7 @@ func (q *qwen3NextModel) Replacements() []string {
|
||||
"linear_attn.in_proj_b", "ssm_beta",
|
||||
|
||||
"linear_attn.conv1d", "ssm_conv1d",
|
||||
"linear_attn.dt_bias", "ssm_dt",
|
||||
"linear_attn.dt_bias", "ssm_dt.bias",
|
||||
"linear_attn.dt_proj", "ssm_dt",
|
||||
"linear_attn.A_log", "ssm_a",
|
||||
"linear_attn.norm", "ssm_norm",
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/d4l3k/go-bfloat16"
|
||||
"github.com/ollama/ollama/fs/ggml"
|
||||
)
|
||||
|
||||
@@ -106,11 +108,7 @@ func TestQwen3NextKVLegacyConfig(t *testing.T) {
|
||||
t.Fatalf("unexpected tokenizer pre: got %v want %v", got, want)
|
||||
}
|
||||
|
||||
headCountKV, ok := kv["attention.head_count_kv"].([]uint32)
|
||||
if !ok {
|
||||
t.Fatalf("attention.head_count_kv has unexpected type: %T", kv["attention.head_count_kv"])
|
||||
}
|
||||
if got, want := headCountKV, []uint32{0, 2, 0, 2}; !slices.Equal(got, want) {
|
||||
if got, want := kv["attention.head_count_kv"], uint32(2); got != want {
|
||||
t.Fatalf("unexpected attention.head_count_kv: got %v want %v", got, want)
|
||||
}
|
||||
|
||||
@@ -198,6 +196,7 @@ func TestQwen35KVFromTextConfig(t *testing.T) {
|
||||
VisionModel: qwen3NextVisionConfig{
|
||||
Depth: 2,
|
||||
HiddenSize: 128,
|
||||
IntermediateSize: 512,
|
||||
NumHeads: 4,
|
||||
InChannels: 3,
|
||||
PatchSize: 16,
|
||||
@@ -225,11 +224,7 @@ func TestQwen35KVFromTextConfig(t *testing.T) {
|
||||
t.Fatalf("unexpected architecture: got %v want %v", got, want)
|
||||
}
|
||||
|
||||
headCountKV, ok := kv["attention.head_count_kv"].([]uint32)
|
||||
if !ok {
|
||||
t.Fatalf("attention.head_count_kv has unexpected type: %T", kv["attention.head_count_kv"])
|
||||
}
|
||||
if got, want := headCountKV, []uint32{0, 4, 0, 4}; !slices.Equal(got, want) {
|
||||
if got, want := kv["attention.head_count_kv"], uint32(4); got != want {
|
||||
t.Fatalf("unexpected attention.head_count_kv: got %v want %v", got, want)
|
||||
}
|
||||
|
||||
@@ -248,7 +243,7 @@ func TestQwen35KVFromTextConfig(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("rope.dimension_sections has unexpected type: %T", kv["rope.dimension_sections"])
|
||||
}
|
||||
if got, want := ropeSections, []int32{11, 11, 10}; !slices.Equal(got, want) {
|
||||
if got, want := ropeSections, []int32{11, 11, 10, 0}; !slices.Equal(got, want) {
|
||||
t.Fatalf("unexpected rope.dimension_sections: got %v want %v", got, want)
|
||||
}
|
||||
|
||||
@@ -259,6 +254,254 @@ func TestQwen35KVFromTextConfig(t *testing.T) {
|
||||
if got, want := kv["vision.block_count"], uint32(2); got != want {
|
||||
t.Fatalf("unexpected vision.block_count: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := kv["vision.feed_forward_length"], uint32(512); got != want {
|
||||
t.Fatalf("unexpected vision.feed_forward_length: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35MTPTensors(t *testing.T) {
|
||||
m := &qwen3NextModel{
|
||||
ModelParameters: ModelParameters{
|
||||
ModelType: "qwen3_5",
|
||||
},
|
||||
qwen3NextTextConfig: qwen3NextTextConfig{
|
||||
NumHiddenLayers: 32,
|
||||
NumNextNPredictLayers: 1,
|
||||
},
|
||||
}
|
||||
|
||||
kv := m.KV(&Tokenizer{Vocabulary: &Vocabulary{}})
|
||||
if got, want := kv["block_count"], uint32(33); got != want {
|
||||
t.Fatalf("unexpected block_count: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := kv["nextn_predict_layers"], uint32(1); got != want {
|
||||
t.Fatalf("unexpected nextn_predict_layers: got %v want %v", got, want)
|
||||
}
|
||||
|
||||
tensors := m.Tensors([]Tensor{
|
||||
&fakeTensor{name: "mtp.fc.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
|
||||
&fakeTensor{name: "mtp.pre_fc_norm_embedding.weight", shape: []uint64{2}, data: []float32{0, 1}},
|
||||
&fakeTensor{name: "mtp.pre_fc_norm_hidden.weight", shape: []uint64{2}, data: []float32{0, 1}},
|
||||
&fakeTensor{name: "mtp.norm.weight", shape: []uint64{2}, data: []float32{0, 1}},
|
||||
&fakeTensor{name: "mtp.layers.0.attn_q.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
|
||||
&fakeTensor{name: "mtp.layers.0.ffn_down.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
|
||||
})
|
||||
|
||||
byName := map[string]*ggml.Tensor{}
|
||||
for _, tensor := range tensors {
|
||||
byName[tensor.Name] = tensor
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"blk.32.nextn.eh_proj.weight",
|
||||
"blk.32.nextn.enorm.weight",
|
||||
"blk.32.nextn.hnorm.weight",
|
||||
"blk.32.nextn.shared_head_norm.weight",
|
||||
"blk.32.attn_q.weight",
|
||||
"blk.32.ffn_down.weight",
|
||||
} {
|
||||
if _, ok := byName[name]; !ok {
|
||||
t.Fatalf("missing MTP tensor %q", name)
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range []string{
|
||||
"blk.32.nextn.enorm.weight",
|
||||
"blk.32.nextn.hnorm.weight",
|
||||
"blk.32.nextn.shared_head_norm.weight",
|
||||
} {
|
||||
if got, want := readTensorData(t, byName[name]), []float32{1, 2}; !slices.Equal(got, want) {
|
||||
t.Fatalf("unexpected shifted norm values for %s: got %v want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35NativeSplitKV(t *testing.T) {
|
||||
m := &qwen3NextModel{
|
||||
ModelParameters: ModelParameters{
|
||||
ModelType: "qwen3_5",
|
||||
},
|
||||
TextConfig: &qwen3NextTextConfig{
|
||||
MaxPositionEmbeddings: 16384,
|
||||
HiddenSize: 2560,
|
||||
NumHiddenLayers: 4,
|
||||
IntermediateSize: 9216,
|
||||
NumAttentionHeads: 16,
|
||||
NumKeyValueHeads: 4,
|
||||
HeadDim: 256,
|
||||
RMSNormEPS: 1e-6,
|
||||
FullAttentionInterval: 2,
|
||||
LinearConvKernelDim: 4,
|
||||
LinearKeyHeadDim: 128,
|
||||
LinearNumKeyHeads: 16,
|
||||
LinearNumValueHeads: 32,
|
||||
LinearValueHeadDim: 128,
|
||||
RopeParameters: qwen3NextRopeParams{
|
||||
MRopeInterleaved: true,
|
||||
MropeSection: []int32{11, 11, 10},
|
||||
RopeTheta: 10_000_000,
|
||||
PartialRotaryFactor: 0.25,
|
||||
},
|
||||
},
|
||||
VisionModel: qwen3NextVisionConfig{
|
||||
Depth: 24,
|
||||
HiddenSize: 1024,
|
||||
IntermediateSize: 4096,
|
||||
NumHeads: 16,
|
||||
NumPositionEmbeddings: 2304,
|
||||
InChannels: 3,
|
||||
OutHiddenSize: 2560,
|
||||
PatchSize: 16,
|
||||
SpatialMergeSize: 2,
|
||||
},
|
||||
ImageTokenID: 248056,
|
||||
VisionStartTokenID: 248053,
|
||||
VisionEndTokenID: 248054,
|
||||
}
|
||||
m.VisionModel.ImageMean = []float32{0.5, 0.5, 0.5}
|
||||
m.VisionModel.ImageStd = []float32{0.5, 0.5, 0.5}
|
||||
|
||||
if err := m.parseMore(os.DirFS(t.TempDir())); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
textKV := m.TextKV(&Tokenizer{Vocabulary: &Vocabulary{}})
|
||||
for _, key := range []string{
|
||||
"vision.block_count",
|
||||
"image_token_id",
|
||||
"vision_start_token_id",
|
||||
"vision_end_token_id",
|
||||
"mrope_sections",
|
||||
"rope.mrope_section",
|
||||
"rope.mrope_interleaved",
|
||||
"ssm.v_head_reordered",
|
||||
} {
|
||||
if _, ok := textKV[key]; ok {
|
||||
t.Fatalf("TextKV retained %q", key)
|
||||
}
|
||||
}
|
||||
if got, want := textKV["rope.dimension_sections"], []int32{11, 11, 10, 0}; !slices.Equal(got.([]int32), want) {
|
||||
t.Fatalf("unexpected rope.dimension_sections: got %v want %v", got, want)
|
||||
}
|
||||
|
||||
projectorKV := m.ProjectorKV(&Tokenizer{Vocabulary: &Vocabulary{}})
|
||||
if got, want := projectorKV["general.architecture"], "clip"; got != want {
|
||||
t.Fatalf("unexpected projector architecture: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := projectorKV["clip.projector_type"], "qwen3vl_merger"; got != want {
|
||||
t.Fatalf("unexpected projector type: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := projectorKV["clip.vision.feed_forward_length"], uint32(4096); got != want {
|
||||
t.Fatalf("unexpected projector feed_forward_length: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := projectorKV["clip.vision.image_size"], uint32(768); got != want {
|
||||
t.Fatalf("unexpected projector image_size: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := projectorKV["clip.vision.projection_dim"], uint32(2560); got != want {
|
||||
t.Fatalf("unexpected projector projection_dim: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35ProjectorTensors(t *testing.T) {
|
||||
m := &qwen3NextModel{
|
||||
VisionModel: qwen3NextVisionConfig{Depth: 1},
|
||||
}
|
||||
|
||||
patch := &fakeTensor{
|
||||
name: "v.patch_embed.weight",
|
||||
shape: []uint64{2, 2, 2, 1, 2},
|
||||
data: []float32{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15},
|
||||
}
|
||||
tensors := m.ProjectorTensors([]Tensor{
|
||||
patch,
|
||||
&fakeTensor{name: "v.pos_embed.weight", shape: []uint64{4, 2}, data: []float32{0, 1, 2, 3, 4, 5, 6, 7}},
|
||||
&fakeTensor{name: "v.blk.0.attn_qkv.weight", shape: []uint64{6, 2}, data: make([]float32, 12), sourceDType: "BF16", kind: tensorKindFP16},
|
||||
&fakeTensor{name: "v.blk.0.mlp.linear_fc1.weight", shape: []uint64{8, 2}, data: make([]float32, 16), sourceDType: "BF16", kind: tensorKindFP16},
|
||||
&fakeTensor{name: "token_embd.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
|
||||
&fakeTensor{name: "mtp.fc.weight", shape: []uint64{2, 2}, data: make([]float32, 4)},
|
||||
})
|
||||
|
||||
byName := map[string]*ggml.Tensor{}
|
||||
for _, tensor := range tensors {
|
||||
byName[tensor.Name] = tensor
|
||||
}
|
||||
|
||||
if _, ok := byName["token_embd.weight"]; ok {
|
||||
t.Fatalf("projector tensors included text tensor")
|
||||
}
|
||||
if _, ok := byName["mtp.fc.weight"]; ok {
|
||||
t.Fatalf("projector tensors included MTP tensor")
|
||||
}
|
||||
if got := byName["v.position_embd.weight"]; got == nil || got.Kind != tensorKindFP32 {
|
||||
t.Fatalf("position embedding was not promoted to F32: %#v", got)
|
||||
}
|
||||
if got := byName["v.blk.0.attn_qkv.weight"]; got == nil {
|
||||
t.Fatalf("attn_qkv tensor missing")
|
||||
} else if got.Kind != tensorKindBF16 {
|
||||
t.Fatalf("attn_qkv tensor was not preserved as BF16: %#v", got)
|
||||
}
|
||||
if got := byName["v.blk.0.ffn_up.weight"]; got == nil {
|
||||
t.Fatalf("ffn_up tensor missing")
|
||||
} else if got.Kind != tensorKindBF16 {
|
||||
t.Fatalf("ffn_up tensor was not preserved as BF16: %#v", got)
|
||||
}
|
||||
|
||||
first := byName["v.patch_embd.weight"]
|
||||
if first == nil {
|
||||
t.Fatalf("first patch embedding slice missing")
|
||||
}
|
||||
if got, want := first.Shape, []uint64{2, 2, 1, 2}; !slices.Equal(got, want) {
|
||||
t.Fatalf("unexpected first patch shape: got %v want %v", got, want)
|
||||
}
|
||||
if got, want := readTensorData(t, first), []float32{0, 1, 4, 5, 8, 9, 12, 13}; !slices.Equal(got, want) {
|
||||
t.Fatalf("unexpected first patch data: got %v want %v", got, want)
|
||||
}
|
||||
|
||||
second := byName["v.patch_embd.weight.1"]
|
||||
if second == nil {
|
||||
t.Fatalf("second patch embedding slice missing")
|
||||
}
|
||||
if got, want := readTensorData(t, second), []float32{2, 3, 6, 7, 10, 11, 14, 15}; !slices.Equal(got, want) {
|
||||
t.Fatalf("unexpected second patch data: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35BF16ProjectorWriterPreservesSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "tensor.bin")
|
||||
values := []float32{1, -2, 3.5, 4.25}
|
||||
raw := bfloat16.EncodeFloat32(values)
|
||||
if err := os.WriteFile(path, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st := safetensor{
|
||||
fs: os.DirFS(dir),
|
||||
path: "tensor.bin",
|
||||
dtype: "BF16",
|
||||
offset: 0,
|
||||
size: int64(len(raw)),
|
||||
tensorBase: &tensorBase{
|
||||
name: "v.blk.0.attn_qkv.weight",
|
||||
shape: []uint64{2, 2},
|
||||
},
|
||||
}
|
||||
tensor := &ggml.Tensor{
|
||||
Name: "v.blk.0.attn_qkv.weight",
|
||||
Kind: tensorKindBF16,
|
||||
Shape: []uint64{2, 2},
|
||||
WriterTo: tensorBF16Writer{tensor: st},
|
||||
}
|
||||
|
||||
var got bytes.Buffer
|
||||
if n, err := tensor.WriteTo(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
} else if n != int64(len(raw)) {
|
||||
t.Fatalf("unexpected byte count: got %d want %d", n, len(raw))
|
||||
}
|
||||
if !bytes.Equal(got.Bytes(), raw) {
|
||||
t.Fatalf("BF16 writer changed source bytes: got %x want %x", got.Bytes(), raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen3NextReplacements(t *testing.T) {
|
||||
@@ -273,6 +516,12 @@ func TestQwen3NextReplacements(t *testing.T) {
|
||||
if got, want := r.Replace("model.layers.1.linear_attn.in_proj_qkvz.weight"), "blk.1.ssm_in.weight"; got != want {
|
||||
t.Fatalf("unexpected legacy replacement: got %q want %q", got, want)
|
||||
}
|
||||
if got, want := r.Replace("model.layers.1.linear_attn.dt_bias"), "blk.1.ssm_dt.bias"; got != want {
|
||||
t.Fatalf("unexpected dt bias replacement: got %q want %q", got, want)
|
||||
}
|
||||
if got, want := r.Replace("model.layers.1.linear_attn.dt_proj.weight"), "blk.1.ssm_dt.weight"; got != want {
|
||||
t.Fatalf("unexpected dt projection replacement: got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35ReordersVHeads(t *testing.T) {
|
||||
@@ -399,6 +648,33 @@ func TestQwen35ReordersSsmBetaRows(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35ReordersSsmDtBias(t *testing.T) {
|
||||
m := &qwen3NextModel{
|
||||
ModelParameters: ModelParameters{
|
||||
ModelType: "qwen3_5",
|
||||
},
|
||||
qwen3NextTextConfig: qwen3NextTextConfig{
|
||||
LinearNumKeyHeads: 2,
|
||||
LinearNumValueHeads: 4,
|
||||
},
|
||||
}
|
||||
|
||||
out := m.Tensors([]Tensor{
|
||||
&fakeTensor{
|
||||
name: "blk.0.ssm_dt.bias",
|
||||
shape: []uint64{4},
|
||||
data: []float32{0, 1, 2, 3},
|
||||
},
|
||||
})
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("unexpected output tensor count: got %d want 1", len(out))
|
||||
}
|
||||
|
||||
if got, want := readTensorData(t, out[0]), []float32{0, 2, 1, 3}; !slices.Equal(got, want) {
|
||||
t.Fatalf("unexpected ssm_dt.bias data: got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQwen35ReordersConv1DChannelDim(t *testing.T) {
|
||||
m := &qwen3NextModel{
|
||||
ModelParameters: ModelParameters{
|
||||
|
||||
+257
-2
@@ -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")
|
||||
|
||||
Loaded 100 of 1202 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user