mirror of
https://github.com/ollama/ollama.git
synced 2026-09-08 12:13:43 -04:00
Compare commits
94
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c71d8d7ca | ||
|
|
00381496a3 | ||
|
|
5e9636fa05 | ||
|
|
630882621b | ||
|
|
0e93ccc2cd | ||
|
|
e7766a4a47 | ||
|
|
be7de10c41 | ||
|
|
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 | ||
|
|
f866e7608f | ||
|
|
bab59072fb | ||
|
|
7c2c36bda2 | ||
|
|
d319227df0 | ||
|
|
2d84ec939c | ||
|
|
15e6076d79 | ||
|
|
4017af96cd | ||
|
|
534342e7e2 | ||
|
|
9ba5a04914 | ||
|
|
938ca6e274 | ||
|
|
8f39fff70b | ||
|
|
4fe5609563 | ||
|
|
917324bb4d | ||
|
|
c7c2837c96 | ||
|
|
b6447caebc | ||
|
|
bad32c7244 | ||
|
|
ab2e005bf7 | ||
|
|
321cc8a2ba | ||
|
|
87288ced4f | ||
|
|
2bbe2405fe | ||
|
|
bd21678b16 | ||
|
|
088dfd89a8 | ||
|
|
3cab8a7b02 | ||
|
|
03aee88186 | ||
|
|
ec9b4e9e47 | ||
|
|
4656a07e56 | ||
|
|
30f86cb9dd | ||
|
|
ea01af6f76 | ||
|
|
c2ebb4d57c | ||
|
|
590109c835 | ||
|
|
b4442c6d17 | ||
|
|
85ff8e4a21 | ||
|
|
160660e572 | ||
|
|
3b43b9bc4b | ||
|
|
21883571b7 | ||
|
|
ce99f24731 | ||
|
|
04f5f0cdb4 | ||
|
|
fb36a01ffe | ||
|
|
0c65ed33bc | ||
|
|
22d6c817f8 | ||
|
|
ca01373b28 | ||
|
|
24e038d56a | ||
|
|
5d1021603a | ||
|
|
8e05d734b9 | ||
|
|
05e0f21bec | ||
|
|
ff23dd343f | ||
|
|
123b300af6 | ||
|
|
57653b8e42 | ||
|
|
a50ce61c54 | ||
|
|
2bb7ea00d2 | ||
|
|
55fa80d07a | ||
|
|
b9cb535407 | ||
|
|
031baef094 | ||
|
|
7d271e6dc9 | ||
|
|
c88dae2d6b | ||
|
|
9e3618d663 | ||
|
|
e585ecd11f |
No files matched your search
+264
-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,80 +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 }}.tar.in ;;
|
||||
lib/ollama/include*) echo $COMPONENT >>ollama-${{ matrix.os }}-${{ matrix.arch }}.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:
|
||||
@@ -489,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 }}
|
||||
@@ -502,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:
|
||||
@@ -541,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:
|
||||
@@ -559,6 +702,21 @@ jobs:
|
||||
- name: Copy install scripts to dist
|
||||
run: |
|
||||
cp scripts/install.sh dist/install.sh
|
||||
- name: Verify release artifacts
|
||||
run: |
|
||||
required=(
|
||||
dist/OllamaSetup.exe
|
||||
dist/install.ps1
|
||||
dist/install.sh
|
||||
dist/ollama-windows-amd64.zip
|
||||
dist/ollama-windows-arm64.zip
|
||||
)
|
||||
for payload in "${required[@]}"; do
|
||||
if [ ! -f "$payload" ]; then
|
||||
echo "::error::Missing expected release artifact: $payload"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
- name: Generate checksum file
|
||||
run: find . -type f -not -name 'sha256sum.txt' | xargs sha256sum | tee sha256sum.txt
|
||||
working-directory: dist
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
name: test-llamacpp-update
|
||||
|
||||
# PR validation artifacts from this workflow are intentionally unsigned and not
|
||||
# notarized. They are for llama.cpp update testing only and must not be
|
||||
# published as release artifacts.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'LLAMA_CPP_VERSION'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CGO_CFLAGS: '-O3'
|
||||
CGO_CXXFLAGS: '-O3'
|
||||
|
||||
jobs:
|
||||
setup-environment:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
id: goflags
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="0.0.0-llamacpp-${GITHUB_SHA::7}"
|
||||
{
|
||||
echo "GOFLAGS='-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${VERSION}\" \"-X=github.com/ollama/ollama/server.mode=release\"'"
|
||||
echo "VERSION=${VERSION}"
|
||||
echo "vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION)"
|
||||
} >>"${GITHUB_OUTPUT}"
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Build unsigned Darwin runtime
|
||||
run: ./scripts/build_darwin.sh build package
|
||||
- name: Log build results
|
||||
run: ls -l dist/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-darwin.tgz
|
||||
path: dist/ollama-darwin.tgz
|
||||
compression-level: 0
|
||||
|
||||
# Build payload export stages independently and combine the exported
|
||||
# filesystem artifacts below. This preserves parallelism without Docker
|
||||
# registry credentials or oversized GitHub layer caches.
|
||||
linux-payloads:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: amd64
|
||||
target: publish-llama-server-rocm_v7_2
|
||||
payload: rocm_v7_2
|
||||
- arch: amd64
|
||||
target: publish-llama-server-vulkan
|
||||
payload: vulkan
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack5
|
||||
payload: cuda_jetpack5
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack6
|
||||
payload: cuda_jetpack6
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: ${{ matrix.target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-${{ matrix.payload }}
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
linux-go:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: publish-go
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux Go payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-go
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
# MLX payloads are intentionally excluded from this workflow; the Dockerfile
|
||||
# still exposes publish-mlx for a separate MLX-specific workflow.
|
||||
linux-bundles:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: [linux-payloads, linux-go]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: linux-payload-${{ matrix.arch }}-*
|
||||
path: ${{ runner.temp }}/payloads
|
||||
merge-multiple: true
|
||||
- name: Assemble Linux payload tree
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
src="${{ runner.temp }}/payloads"
|
||||
arch="${{ matrix.arch }}"
|
||||
dest="dist/linux-${arch}"
|
||||
copy_payload() {
|
||||
local name="$1"
|
||||
local payload="${src}/linux-payload-${arch}-${name}.tar.zst"
|
||||
if [ ! -f "${payload}" ]; then
|
||||
echo "missing payload ${payload}"
|
||||
exit 1
|
||||
fi
|
||||
zstd -d <"${payload}" | tar -C "${dest}" -xf -
|
||||
}
|
||||
|
||||
mkdir -p "${dest}"
|
||||
copy_payload go
|
||||
copy_payload cpu
|
||||
copy_payload cuda_v12
|
||||
copy_payload cuda_v13
|
||||
if [ "${arch}" = "amd64" ]; then
|
||||
copy_payload vulkan
|
||||
copy_payload rocm_v7_2
|
||||
else
|
||||
copy_payload cuda_jetpack5
|
||||
copy_payload cuda_jetpack6
|
||||
fi
|
||||
|
||||
./scripts/deduplicate_cuda_libs.sh "${dest}"
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/linux-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"${base}/bin/ollama" \
|
||||
"${base}/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "${payload}" ] || { echo "missing ${payload}"; exit 1; }
|
||||
done
|
||||
- name: Create archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "${COMPONENT}" in
|
||||
bin/ollama*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/linux-${{ matrix.arch }}
|
||||
- name: Log archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
echo "${ARCHIVE}"
|
||||
cat "${ARCHIVE}"
|
||||
done
|
||||
- name: Create Linux archives
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/linux-${{ matrix.arch }} -T "${ARCHIVE}" --owner 0 --group 0 | zstd -19 -T0 >"$(basename "${ARCHIVE//.*/}.tar.zst")" &
|
||||
done
|
||||
wait
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
path: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-amd64-rocm.tar.zst
|
||||
path: ollama-linux-amd64-rocm.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack5.tar.zst
|
||||
path: ollama-linux-arm64-jetpack5.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack6.tar.zst
|
||||
path: ollama-linux-arm64-jetpack6.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
preset: ['CPU']
|
||||
build-steps: ['cpu cpuArm64']
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 12'
|
||||
build-steps: cuda12
|
||||
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 7'
|
||||
build-steps: rocm7
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: Vulkan
|
||||
build-steps: vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- if: startsWith(matrix.preset, 'CUDA ')
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$cudaPath = (Resolve-Path "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\*").path
|
||||
echo "$cudaPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- if: startsWith(matrix.preset, 'ROCm')
|
||||
name: Install ROCm ${{ matrix.rocm-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList '-install' -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$hipPath = (Resolve-Path "C:\Program Files\AMD\ROCm\*").path
|
||||
echo "$hipPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "CC=$hipPath\bin\clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIPCXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIP_PLATFORM=amd" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CMAKE_PREFIX_PATH=$hipPath" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: matrix.preset == 'Vulkan'
|
||||
name: Install Vulkan
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList "-c","--am","--al","in" -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: ${{ !cancelled() && matrix.preset != 'CPU' && steps.cache-install.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build Windows dependencies
|
||||
run: |
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-build:
|
||||
runs-on: windows
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
$version=& gcc -v 2>&1
|
||||
$version=$version -join "`n"
|
||||
echo "gcc is $version"
|
||||
if ($version -notmatch 'clang') {
|
||||
echo "ERROR: GCC must be clang for proper utf16 handling"
|
||||
exit 1
|
||||
}
|
||||
$ErrorActionPreference='Stop'
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Build Windows binaries and app launchers
|
||||
run: ./scripts/build_windows.ps1 ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-windows-amd64
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-package:
|
||||
runs-on: windows
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: build-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Copy unsigned install script
|
||||
run: Copy-Item -Path .\scripts\install.ps1 -Destination .\dist\install.ps1 -ErrorAction Stop
|
||||
- name: Log dist contents after download
|
||||
run: gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Build unsigned Windows installer and zips
|
||||
run: ./scripts/build_windows.ps1 deps installer zip
|
||||
- name: Log contents after build
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- name: Verify Windows package outputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/ollama-windows-amd64.zip \
|
||||
dist/ollama-windows-arm64.zip \
|
||||
dist/ollama-windows-amd64-rocm.zip \
|
||||
dist/OllamaSetup.exe \
|
||||
dist/install.ps1
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64.zip
|
||||
path: dist/ollama-windows-amd64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-arm64.zip
|
||||
path: dist/ollama-windows-arm64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64-rocm.zip
|
||||
path: dist/ollama-windows-amd64-rocm.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OllamaSetup.exe
|
||||
path: dist/OllamaSetup.exe
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: install.ps1
|
||||
path: dist/install.ps1
|
||||
compression-level: 0
|
||||
+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
-319
@@ -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,301 +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
|
||||
)
|
||||
|
||||
# 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 4",
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
+189
-98
@@ -37,113 +37,171 @@ RUN dnf install -y unzip \
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
ENV LDFLAGS=-s
|
||||
|
||||
FROM base AS cpu
|
||||
#
|
||||
# GPU toolchain stages — provide compilers for llama-server GPU builds
|
||||
#
|
||||
|
||||
FROM base AS cpu-deps
|
||||
RUN dnf install -y gcc-toolset-11-gcc gcc-toolset-11-gcc-c++
|
||||
ENV PATH=/opt/rh/gcc-toolset-11/root/usr/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CPU' \
|
||||
&& cmake --build --preset 'CPU' -- -l $(nproc) \
|
||||
&& cmake --install build --component CPU --strip
|
||||
|
||||
FROM base AS cuda-11
|
||||
ARG CUDA11VERSION=11.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA11VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-11/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 11' \
|
||||
&& cmake --build --preset 'CUDA 11' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS cuda-12
|
||||
FROM base AS cuda-12-deps
|
||||
ARG CUDA12VERSION=12.8
|
||||
RUN dnf install -y cuda-toolkit-${CUDA12VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-12/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 12' \
|
||||
&& cmake --build --preset 'CUDA 12' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
|
||||
FROM base AS cuda-13
|
||||
FROM base AS cuda-13-deps
|
||||
ARG CUDA13VERSION=13.0
|
||||
RUN dnf install -y cuda-toolkit-${CUDA13VERSION//./-}
|
||||
ENV PATH=/usr/local/cuda-13/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'CUDA 13' \
|
||||
&& cmake --build --preset 'CUDA 13' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS rocm-7-deps
|
||||
ENV PATH=/opt/rocm/llvm/bin:/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:$PATH
|
||||
|
||||
FROM base AS rocm-7
|
||||
ENV PATH=/opt/rocm/hcc/bin:/opt/rocm/hip/bin:/opt/rocm/bin:/opt/rocm/hcc/bin:$PATH
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'ROCm 7' \
|
||||
&& cmake --build --preset 'ROCm 7' -- -l $(nproc) \
|
||||
&& cmake --install build --component HIP --strip
|
||||
RUN rm -f dist/lib/ollama/rocm/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 5' \
|
||||
&& cmake --build --preset 'JetPack 5' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'JetPack 6' \
|
||||
&& cmake --build --preset 'JetPack 6' -- -l $(nproc) \
|
||||
&& cmake --install build --component CUDA --strip
|
||||
|
||||
FROM base AS vulkan
|
||||
FROM base AS vulkan-deps
|
||||
ARG VULKANVERSION
|
||||
RUN ln -s /usr/bin/python3 /usr/bin/python \
|
||||
&& wget https://sdk.lunarg.com/sdk/download/${VULKANVERSION}/linux/vulkansdk-linux-x86_64-${VULKANVERSION}.tar.xz -O /tmp/vulkansdk.tar.xz \
|
||||
&& tar xvf /tmp/vulkansdk.tar.xz -C /tmp \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 vulkan-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 spirv-headers \
|
||||
&& /tmp/${VULKANVERSION}/vulkansdk -j 8 shaderc \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/include/* /usr/local/include/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/lib/* /usr/local/lib \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/share/* /usr/local/share/ \
|
||||
&& cp -r /tmp/${VULKANVERSION}/x86_64/bin/* /usr/local/bin/ \
|
||||
&& rm -rf /tmp/${VULKANVERSION} /tmp/vulkansdk.tar.xz
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
ENV VULKAN_SDK=/usr/local
|
||||
|
||||
#
|
||||
# llama-server stages — rebuild when LLAMA_CPP_VERSION, llama/server/, or llama/compat/ changes.
|
||||
#
|
||||
# CPU stage: llama-server + ggml-base + ggml-cpu variants → lib/ollama/
|
||||
# GPU stages: GPU backend .so only → lib/ollama/<variant>/
|
||||
#
|
||||
|
||||
FROM cpu-deps AS llama-server-cpu
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake --preset 'Vulkan' \
|
||||
&& cmake --build --preset 'Vulkan' -- -l $(nproc) \
|
||||
&& cmake --install build --component Vulkan --strip
|
||||
cmake -S llama/server --preset cpu \
|
||||
&& cmake --build build/llama-server-cpu -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cpu --component llama-server --strip \
|
||||
&& for lib in \
|
||||
/usr/lib64/libgomp.so* \
|
||||
/usr/lib64/libomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libgomp.so* \
|
||||
/opt/rh/gcc-toolset-11/root/usr/lib64/libomp.so*; do \
|
||||
[ -e "$lib" ] && cp -a "$lib" dist/lib/ollama/ || true; \
|
||||
done
|
||||
|
||||
FROM scratch AS publish-llama-server-cpu
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM cuda-12-deps AS llama-server-cuda_v12
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v12_linux \
|
||||
&& cmake --build build/llama-server-cuda_v12 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v12 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_v12
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM cuda-13-deps AS llama-server-cuda_v13
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_v13_linux \
|
||||
&& cmake --build build/llama-server-cuda_v13 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_v13 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_v13
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM rocm-7-deps AS llama-server-rocm_v7_2
|
||||
ENV CC=clang CXX=clang++
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset rocm_v7_2_linux \
|
||||
&& cmake --build build/llama-server-rocm_v7_2 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-rocm_v7_2 --component llama-server --strip
|
||||
RUN rm -f dist/lib/ollama/rocm_v7_2/rocblas/library/*gfx90[06]*
|
||||
|
||||
FROM scratch AS publish-llama-server-rocm_v7_2
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM vulkan-deps AS llama-server-vulkan
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset vulkan \
|
||||
&& cmake --build build/llama-server-vulkan -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-vulkan --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-vulkan
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# JetPack stages — self-contained with their own base images
|
||||
#
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK5VERSION} AS jetpack-5
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack5 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack5 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack5 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack5
|
||||
COPY --from=jetpack-5 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 nvcr.io/nvidia/l4t-jetpack:${JETPACK6VERSION} AS jetpack-6
|
||||
ARG CMAKEVERSION
|
||||
ARG NINJAVERSION
|
||||
RUN apt-get update && apt-get install -y curl ccache git unzip \
|
||||
&& curl -fsSL https://github.com/Kitware/CMake/releases/download/v${CMAKEVERSION}/cmake-${CMAKEVERSION}-linux-$(uname -m).tar.gz | tar xz -C /usr/local --strip-components 1 \
|
||||
&& curl -fsSL -o /tmp/ninja.zip https://github.com/ninja-build/ninja/releases/download/v${NINJAVERSION}/ninja-linux-aarch64.zip \
|
||||
&& unzip /tmp/ninja.zip -d /usr/local/bin \
|
||||
&& rm /tmp/ninja.zip
|
||||
ENV CMAKE_GENERATOR=Ninja
|
||||
COPY LLAMA_CPP_VERSION .
|
||||
COPY llama/server llama/server
|
||||
COPY llama/compat llama/compat
|
||||
RUN --mount=type=cache,target=/root/.ccache \
|
||||
cmake -S llama/server --preset llama_cuda_jetpack6 \
|
||||
&& cmake --build build/llama-server-cuda_jetpack6 -- -l $(nproc) \
|
||||
&& cmake --install build/llama-server-cuda_jetpack6 --component llama-server --strip
|
||||
|
||||
FROM scratch AS publish-llama-server-cuda_jetpack6
|
||||
COPY --from=jetpack-6 dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# MLX stage
|
||||
#
|
||||
|
||||
FROM base AS mlx
|
||||
ARG CUDA13VERSION=13.0
|
||||
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 +212,7 @@ ENV LAPACK_INCLUDE_DIRS=/usr/include/openblas
|
||||
ENV CGO_LDFLAGS="-L/usr/local/cuda-13/lib64 -L/usr/local/cuda-13/targets/x86_64-linux/lib/stubs"
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
COPY CMakeLists.txt CMakePresets.json .
|
||||
COPY ml/backend/ggml/ggml ml/backend/ggml/ggml
|
||||
COPY cmake cmake
|
||||
COPY x/imagegen/mlx x/imagegen/mlx
|
||||
COPY go.mod go.sum .
|
||||
COPY MLX_VERSION MLX_C_VERSION .
|
||||
@@ -170,9 +228,15 @@ RUN --mount=type=cache,target=/root/.ccache \
|
||||
&& if [ -f /tmp/local-mlx-c/CMakeLists.txt ]; then \
|
||||
export OLLAMA_MLX_C_SOURCE=/tmp/local-mlx-c; \
|
||||
fi \
|
||||
&& cmake --preset 'MLX CUDA 13' -DBLAS_INCLUDE_DIRS=/usr/include/openblas -DLAPACK_INCLUDE_DIRS=/usr/include/openblas \
|
||||
&& 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}}
|
||||
|
||||
FROM scratch AS publish-mlx
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
#
|
||||
# Go build
|
||||
#
|
||||
|
||||
FROM base AS build
|
||||
WORKDIR /go/src/github.com/ollama/ollama
|
||||
@@ -190,35 +254,62 @@ ENV CGO_CXXFLAGS="${CGO_CXXFLAGS}"
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -trimpath -buildmode=pie -o /bin/ollama .
|
||||
|
||||
FROM scratch AS publish-go
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
#
|
||||
# Assembly stages — combine llama-server variants + GPU runtime libs
|
||||
#
|
||||
|
||||
FROM --platform=linux/amd64 scratch AS amd64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-vulkan dist/lib/ollama /lib/ollama/
|
||||
COPY --from=mlx /go/src/github.com/ollama/ollama/dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64
|
||||
# COPY --from=cuda-11 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=cuda-12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=cuda-13 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v12 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-cuda_v13 dist/lib/ollama /lib/ollama/
|
||||
COPY --from=jetpack-5 dist/lib/ollama/ /lib/ollama/
|
||||
COPY --from=jetpack-6 dist/lib/ollama/ /lib/ollama/
|
||||
|
||||
FROM scratch AS rocm
|
||||
COPY --from=rocm-7 dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-cpu dist/lib/ollama /lib/ollama
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama
|
||||
|
||||
FROM ${FLAVOR} AS archive
|
||||
COPY --from=cpu dist/lib/ollama /lib/ollama
|
||||
FROM --platform=linux/amd64 scratch AS amd64-archive
|
||||
COPY --from=amd64 /lib/ollama /lib/ollama/
|
||||
COPY --from=llama-server-rocm_v7_2 dist/lib/ollama /lib/ollama/
|
||||
|
||||
FROM --platform=linux/arm64 scratch AS arm64-archive
|
||||
COPY --from=arm64 /lib/ollama /lib/ollama/
|
||||
|
||||
FROM ${TARGETARCH}-archive AS archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
FROM ${FLAVOR} AS image-archive
|
||||
COPY --from=build /bin/ollama /bin/ollama
|
||||
|
||||
FROM ubuntu:24.04
|
||||
RUN apt-get update \
|
||||
ARG APT_MIRROR=http://archive.ubuntu.com/ubuntu
|
||||
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 \
|
||||
-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 @@
|
||||
b9452
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0726ca922fc902c4c61ef9c27d94132be418e945
|
||||
fba4470b89073180056c9ea46c443051375f7399
|
||||
+1
-1
@@ -1 +1 @@
|
||||
38ad257088fb2193ad47e527cf6534a689f30943
|
||||
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)
|
||||
@@ -55,7 +55,7 @@ The official [Ollama Docker image](https://hub.docker.com/r/ollama/ollama) `olla
|
||||
ollama
|
||||
```
|
||||
|
||||
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `claude`, `codex`, `openclaw` and more.
|
||||
You'll be prompted to run a model or connect Ollama to your existing agents or applications such as `Claude Code`, `OpenClaw`, `OpenCode` , `Codex`, `Copilot`, and more.
|
||||
|
||||
### Coding
|
||||
|
||||
@@ -65,7 +65,7 @@ To launch a specific integration:
|
||||
ollama launch claude
|
||||
```
|
||||
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
Supported integrations include [Claude Code](https://docs.ollama.com/integrations/claude-code), [Codex](https://docs.ollama.com/integrations/codex), [Copilot CLI](https://docs.ollama.com/integrations/copilot-cli), [Droid](https://docs.ollama.com/integrations/droid), and [OpenCode](https://docs.ollama.com/integrations/opencode).
|
||||
|
||||
### AI assistant
|
||||
|
||||
|
||||
+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
|
||||
}
|
||||
|
||||
@@ -368,6 +372,16 @@ func (c *Client) List(ctx context.Context) (*ListResponse, error) {
|
||||
return &lr, nil
|
||||
}
|
||||
|
||||
// ModelRecommendationsExperimental lists model recommendations from the local
|
||||
// server's experimental recommendations endpoint.
|
||||
func (c *Client) ModelRecommendationsExperimental(ctx context.Context) (*ModelRecommendationsResponse, error) {
|
||||
var resp ModelRecommendationsResponse
|
||||
if err := c.do(ctx, http.MethodGet, "/api/experimental/model-recommendations", nil, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &resp, nil
|
||||
}
|
||||
|
||||
// ListRunning lists running models.
|
||||
func (c *Client) ListRunning(ctx context.Context) (*ProcessResponse, error) {
|
||||
var lr ProcessResponse
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+75
-32
@@ -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"`
|
||||
|
||||
@@ -802,6 +809,21 @@ type ListResponse struct {
|
||||
Models []ListModelResponse `json:"models"`
|
||||
}
|
||||
|
||||
// ModelRecommendationsResponse is the response from [Client.ModelRecommendationsExperimental].
|
||||
type ModelRecommendationsResponse struct {
|
||||
Recommendations []ModelRecommendation `json:"recommendations"`
|
||||
}
|
||||
|
||||
// ModelRecommendation is a single recommendation entry in [ModelRecommendationsResponse].
|
||||
type ModelRecommendation struct {
|
||||
Model string `json:"model"`
|
||||
Description string `json:"description"`
|
||||
ContextLength int `json:"context_length,omitempty"`
|
||||
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
|
||||
VRAMBytes int64 `json:"vram_bytes,omitempty"`
|
||||
RequiredPlan string `json:"required_plan,omitempty"`
|
||||
}
|
||||
|
||||
// ProcessResponse is the response from [Client.Process].
|
||||
type ProcessResponse struct {
|
||||
Models []ProcessModelResponse `json:"models"`
|
||||
@@ -809,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].
|
||||
@@ -909,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.
|
||||
@@ -1031,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:
|
||||
@@ -1071,16 +1107,17 @@ 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low")
|
||||
// ThinkValue represents a value that can be a boolean or a string ("high", "medium", "low", "max")
|
||||
type ThinkValue struct {
|
||||
// Value can be a bool or string
|
||||
Value interface{}
|
||||
@@ -1096,7 +1133,7 @@ func (t *ThinkValue) IsValid() bool {
|
||||
case bool:
|
||||
return true
|
||||
case string:
|
||||
return v == "high" || v == "medium" || v == "low"
|
||||
return v == "high" || v == "medium" || v == "low" || v == "max"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1130,8 +1167,8 @@ func (t *ThinkValue) Bool() bool {
|
||||
case bool:
|
||||
return v
|
||||
case string:
|
||||
// Any string value ("high", "medium", "low") means thinking is enabled
|
||||
return v == "high" || v == "medium" || v == "low"
|
||||
// Any string value ("high", "medium", "low", "max") means thinking is enabled
|
||||
return v == "high" || v == "medium" || v == "low" || v == "max"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
@@ -1169,14 +1206,14 @@ func (t *ThinkValue) UnmarshalJSON(data []byte) error {
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err == nil {
|
||||
// Validate string values
|
||||
if s != "high" && s != "medium" && s != "low" {
|
||||
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", true, or false)", s)
|
||||
if s != "high" && s != "medium" && s != "low" && s != "max" {
|
||||
return fmt.Errorf("invalid think value: %q (must be \"high\", \"medium\", \"low\", \"max\", true, or false)", s)
|
||||
}
|
||||
t.Value = s
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", true, or false)")
|
||||
return fmt.Errorf("think must be a boolean or string (\"high\", \"medium\", \"low\", \"max\", true, or false)")
|
||||
}
|
||||
|
||||
// MarshalJSON implements json.Marshaler
|
||||
@@ -1279,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
|
||||
@@ -495,6 +546,11 @@ func TestThinking_UnmarshalJSON(t *testing.T) {
|
||||
input: `{ "think": "low" }`,
|
||||
expectedThinking: &ThinkValue{Value: "low"},
|
||||
},
|
||||
{
|
||||
name: "string_max",
|
||||
input: `{ "think": "max" }`,
|
||||
expectedThinking: &ThinkValue{Value: "max"},
|
||||
},
|
||||
{
|
||||
name: "invalid_string",
|
||||
input: `{ "think": "invalid" }`,
|
||||
|
||||
@@ -157,10 +157,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
if u := os.Getenv("OLLAMA_UPDATE_URL"); u != "" {
|
||||
updater.UpdateCheckURLBase = u
|
||||
}
|
||||
|
||||
// Detect if this is a first start after an upgrade, in
|
||||
// which case we need to do some cleanup
|
||||
var skipMove bool
|
||||
|
||||
+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;
|
||||
|
||||
@@ -83,6 +83,29 @@ func resolvePath(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func ollamaServeArgs(args []string) bool {
|
||||
if len(args) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
switch strings.Trim(filepath.Base(args[0]), `"`) {
|
||||
case "ollama", "ollama.exe":
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
for _, rawArg := range args[1:] {
|
||||
arg := strings.Trim(rawArg, `"`)
|
||||
if strings.HasPrefix(arg, "-") {
|
||||
continue
|
||||
}
|
||||
|
||||
return arg == "serve" || arg == "start"
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanup checks the pid file for a running ollama process
|
||||
// and shuts it down gracefully if it is running
|
||||
func cleanup() error {
|
||||
|
||||
@@ -205,6 +205,63 @@ func TestServerCmdCloudSettingEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOllamaServeArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "system ollama serve",
|
||||
args: []string{"ollama", "serve"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "relative path ollama serve",
|
||||
args: []string{"./ollama", "serve"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "serve after other flags",
|
||||
args: []string{"./ollama", "--verbose", "serve"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "start alias",
|
||||
args: []string{"ollama", "start"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "launch command",
|
||||
args: []string{"ollama", "launch", "opencode"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "run command with model named serve",
|
||||
args: []string{"ollama", "run", "serve"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "launch command with serve in passthrough args",
|
||||
args: []string{"ollama", "launch", "codex", "--", "-p", "serve"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "different executable",
|
||||
args: []string{"go", "run", "serve"},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := ollamaServeArgs(tt.args); got != tt.want {
|
||||
t.Fatalf("ollamaServeArgs(%v) = %v, want %v", tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetInferenceInfo(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -46,7 +46,17 @@ func terminated(pid int) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// reapServers kills all ollama processes except our own
|
||||
func ollamaServeProcess(pid int) bool {
|
||||
output, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "args=").Output()
|
||||
if err != nil {
|
||||
slog.Debug("failed to inspect ollama process", "pid", pid, "err", err)
|
||||
return false
|
||||
}
|
||||
|
||||
return ollamaServeArgs(strings.Fields(strings.TrimSpace(string(output))))
|
||||
}
|
||||
|
||||
// reapServers kills external ollama serve processes except our own.
|
||||
func reapServers() error {
|
||||
// Get our own PID to avoid killing ourselves
|
||||
currentPID := os.Getpid()
|
||||
@@ -82,6 +92,9 @@ func reapServers() error {
|
||||
if pid == currentPID {
|
||||
continue
|
||||
}
|
||||
if !ollamaServeProcess(pid) {
|
||||
continue
|
||||
}
|
||||
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
|
||||
@@ -101,7 +101,29 @@ func terminated(pid int) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// reapServers kills all ollama processes except our own
|
||||
func ollamaServeProcess(pid int) bool {
|
||||
cmd := exec.Command("wmic", "process", "where", fmt.Sprintf("ProcessId=%d", pid), "get", "CommandLine", "/value")
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
slog.Debug("failed to inspect ollama process", "pid", pid, "err", err)
|
||||
return false
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(output), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
commandLine, ok := strings.CutPrefix(line, "CommandLine=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
return ollamaServeArgs(strings.Fields(strings.ToLower(commandLine)))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// reapServers kills external ollama serve processes except our own.
|
||||
func reapServers() error {
|
||||
// Get current process ID to avoid killing ourselves
|
||||
currentPID := os.Getpid()
|
||||
@@ -138,6 +160,9 @@ func reapServers() error {
|
||||
if pid == currentPID {
|
||||
continue
|
||||
}
|
||||
if !ollamaServeProcess(pid) {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
|
||||
+10
-7
@@ -1201,13 +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": {},
|
||||
"codex": {},
|
||||
"opencode": {},
|
||||
"droid": {},
|
||||
"pi": {},
|
||||
"launch": {},
|
||||
"openclaw": {},
|
||||
"claude": {},
|
||||
"hermes": {},
|
||||
"codex": {},
|
||||
"codex-app": {},
|
||||
"copilot": {},
|
||||
"opencode": {},
|
||||
"droid": {},
|
||||
"pi": {},
|
||||
}
|
||||
if lastHomeView != "chat" {
|
||||
if _, ok := validLaunchView[lastHomeView]; !ok {
|
||||
|
||||
@@ -107,6 +107,36 @@ func TestStore(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings disabled home view falls back to launch", func(t *testing.T) {
|
||||
if err := s.SetSettings(Settings{LastHomeView: "claude-desktop"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loaded, err := s.Settings()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if loaded.LastHomeView != "launch" {
|
||||
t.Fatalf("expected disabled LastHomeView to fall back to launch, got %q", loaded.LastHomeView)
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Claude Code</title><path clip-rule="evenodd" d="M20.998 10.949H24v3.102h-3v3.028h-1.487V20H18v-2.921h-1.487V20H15v-2.921H9V20H7.488v-2.921H6V20H4.487v-2.921H3V14.05H0V10.95h3V5h17.998v5.949zM6 10.949h1.488V8.102H6v2.847zm10.51 0H18V8.102h-1.49v2.847z" fill="#D97757" fill-rule="evenodd"></path></svg>
|
||||
|
After Width: | Height: | Size: 424 B |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 2.5 24 19"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z" fill="white"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 2.5 24 19"><path d="M19.245 5.364c1.322 1.36 1.877 3.216 2.11 5.817.622 0 1.2.135 1.592.654l.73.964c.21.278.323.61.323.955v2.62c0 .339-.173.669-.453.868C20.239 19.602 16.157 21.5 12 21.5c-4.6 0-9.205-2.583-11.547-4.258-.28-.2-.452-.53-.453-.868v-2.62c0-.345.113-.679.321-.956l.73-.963c.392-.517.974-.654 1.593-.654l.029-.297c.25-2.446.81-4.213 2.082-5.52 2.461-2.54 5.71-2.851 7.146-2.864h.198c1.436.013 4.685.323 7.146 2.864zm-7.244 4.328c-.284 0-.613.016-.962.05-.123.447-.305.85-.57 1.108-1.05 1.023-2.316 1.18-2.994 1.18-.638 0-1.306-.13-1.851-.464-.516.165-1.012.403-1.044.996a65.882 65.882 0 00-.063 2.884l-.002.48c-.002.563-.005 1.126-.013 1.69.002.326.204.63.51.765 2.482 1.102 4.83 1.657 6.99 1.657 2.156 0 4.504-.555 6.985-1.657a.854.854 0 00.51-.766c.03-1.682.006-3.372-.076-5.053-.031-.596-.528-.83-1.046-.996-.546.333-1.212.464-1.85.464-.677 0-1.942-.157-2.993-1.18-.266-.258-.447-.661-.57-1.108-.32-.032-.64-.049-.96-.05zm-2.525 4.013c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zm5 0c.539 0 .976.426.976.95v1.753c0 .525-.437.95-.976.95a.964.964 0 01-.976-.95v-1.752c0-.525.437-.951.976-.951zM7.635 5.087c-1.05.102-1.935.438-2.385.906-.975 1.037-.765 3.668-.21 4.224.405.394 1.17.657 1.995.657h.09c.649-.013 1.785-.176 2.73-1.11.435-.41.705-1.433.675-2.47-.03-.834-.27-1.52-.63-1.813-.39-.336-1.275-.482-2.265-.394zm6.465.394c-.36.292-.6.98-.63 1.813-.03 1.037.24 2.06.675 2.47.968.957 2.136 1.104 2.776 1.11h.044c.825 0 1.59-.263 1.995-.657.555-.556.765-3.187-.21-4.224-.45-.468-1.335-.804-2.385-.906-.99-.088-1.875.058-2.265.394zM12 7.615c-.24 0-.525.015-.84.044.03.16.045.336.06.526l-.001.159a2.94 2.94 0 01-.014.25c.225-.022.425-.027.612-.028h.366c.187 0 .387.006.612.028-.015-.146-.015-.277-.015-.409.015-.19.03-.365.06-.526a9.29 9.29 0 00-.84-.044z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,181 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000"><circle cx="500.0" cy="500.0" r="500.0" fill="white"/><g transform="translate(100.0 100.0) scale(0.8333333333333334)"><g transform="translate(0.000000,960.000000) scale(0.100000,-0.100000)"
|
||||
fill="black" stroke="none">
|
||||
<path d="M4485 9589 c-248 -27 -432 -60 -730 -130 -458 -108 -798 -230 -1207
|
||||
-435 -533 -267 -1072 -675 -1358 -1030 -205 -255 -442 -748 -535 -1114 -108
|
||||
-426 -97 -870 29 -1160 73 -169 236 -369 381 -467 139 -94 425 -206 425 -167
|
||||
0 3 -26 32 -58 63 -74 71 -147 182 -184 278 -16 41 -27 77 -25 79 2 3 31 -28
|
||||
63 -68 91 -114 153 -177 231 -235 40 -29 77 -62 83 -73 7 -13 4 -85 -10 -242
|
||||
-10 -123 -24 -281 -30 -353 -6 -71 -29 -296 -51 -500 -135 -1262 -202 -1568
|
||||
-378 -1733 -69 -64 -105 -77 -216 -77 -132 0 -188 20 -271 97 -110 103 -165
|
||||
248 -167 438 -1 123 12 191 60 318 20 51 33 95 29 99 -10 10 -92 -74 -136
|
||||
-137 -153 -223 -204 -504 -146 -790 19 -91 97 -252 161 -333 112 -141 316
|
||||
-237 504 -237 180 0 419 118 591 290 81 81 107 115 196 255 50 78 54 56 13
|
||||
-71 -107 -337 -343 -577 -613 -625 -213 -39 -544 99 -707 295 -93 111 -133
|
||||
199 -173 381 -34 153 -34 154 -46 135 -15 -23 -12 -194 5 -305 42 -280 149
|
||||
-488 327 -636 133 -111 287 -195 465 -254 62 -20 115 -40 117 -44 3 -4 12 -43
|
||||
21 -87 44 -222 199 -385 416 -438 96 -24 265 -21 359 5 138 38 281 148 388
|
||||
297 39 55 48 63 50 45 4 -27 -38 -185 -78 -294 -80 -217 -176 -374 -312 -515
|
||||
-54 -55 -98 -104 -98 -107 0 -4 245 -7 545 -7 l544 0 126 66 c69 36 130 64
|
||||
135 62 6 -2 -10 -28 -34 -59 -46 -59 -48 -69 -10 -69 17 0 32 15 58 59 36 59
|
||||
51 71 87 71 23 0 25 -27 4 -77 -8 -19 -15 -39 -15 -44 0 -12 447 -11 470 1 10
|
||||
5 85 87 168 182 257 297 400 415 317 263 -16 -30 -26 -57 -23 -60 11 -12 210
|
||||
100 383 215 115 76 226 143 375 225 58 32 178 100 266 151 150 87 244 132 244
|
||||
117 0 -4 -78 -86 -174 -182 -207 -208 -246 -254 -334 -386 -48 -71 -122 -157
|
||||
-259 -298 -117 -120 -193 -206 -193 -217 0 -19 8 -20 115 -20 101 0 116 2 122
|
||||
18 19 54 112 249 156 327 124 221 283 436 369 502 87 67 225 152 337 209 103
|
||||
51 297 117 384 130 38 6 29 -2 -92 -75 -74 -45 -170 -107 -214 -139 -106 -76
|
||||
-247 -225 -332 -351 -67 -99 -70 -102 -79 -77 -6 14 -13 26 -18 26 -11 0 -57
|
||||
-75 -94 -155 -63 -136 -124 -376 -103 -401 15 -18 152 -19 166 -2 6 7 18 36
|
||||
27 63 23 72 66 131 217 301 302 339 430 464 590 577 148 104 233 128 520 148
|
||||
205 14 255 9 447 -38 118 -29 183 -65 298 -163 229 -195 538 -577 579 -715 24
|
||||
-83 71 -117 109 -79 9 8 16 27 16 42 0 62 -40 212 -73 277 -158 313 -551 635
|
||||
-922 755 -127 41 -142 55 -52 46 117 -11 231 -35 321 -65 158 -54 259 -123
|
||||
405 -277 164 -174 283 -379 322 -556 11 -51 25 -113 32 -137 36 -128 148 -81
|
||||
117 49 -14 60 -7 106 27 177 36 74 62 94 224 179 254 133 425 260 561 417 272
|
||||
315 403 732 358 1138 -24 218 -84 401 -179 545 -65 98 -155 203 -166 192 -3
|
||||
-3 7 -37 23 -75 136 -309 136 -725 2 -1063 -130 -330 -441 -760 -633 -879 -60
|
||||
-37 -60 -20 2 54 478 577 578 1337 315 2390 -25 99 -86 326 -136 505 -169 611
|
||||
-386 1539 -494 2109 -161 857 -200 998 -442 1606 -161 405 -321 692 -529 950
|
||||
-93 114 -322 343 -433 431 -203 160 -497 332 -737 428 -293 118 -702 220 -978
|
||||
246 -129 12 -406 11 -520 -1z m-267 -214 c301 -47 596 -191 852 -419 85 -76
|
||||
266 -270 345 -371 294 -376 520 -873 640 -1409 45 -199 45 -222 3 -244 -18 -9
|
||||
-45 -30 -61 -45 -25 -23 -28 -33 -23 -60 9 -44 21 -54 121 -98 91 -40 225
|
||||
-124 225 -140 0 -9 -33 5 -211 89 -126 60 -159 94 -167 173 -5 50 14 89 43 89
|
||||
26 0 65 49 65 81 0 52 -22 110 -48 127 -23 15 -37 14 -186 -7 -172 -25 -210
|
||||
-36 -240 -69 -23 -27 -31 -99 -14 -131 16 -29 30 -37 85 -51 26 -7 44 -17 48
|
||||
-29 10 -32 -15 -153 -41 -197 -27 -49 -131 -161 -139 -152 -9 9 36 77 85 126
|
||||
23 24 48 60 56 79 13 31 13 38 -3 71 -10 20 -29 42 -43 48 -14 7 -37 18 -52
|
||||
24 -33 15 -42 39 -63 155 -24 133 -90 393 -135 532 -119 367 -287 686 -483
|
||||
919 -175 208 -434 427 -659 557 -229 131 -498 197 -804 197 -136 0 -185 -4
|
||||
-313 -26 -87 -14 -31 14 137 70 186 61 289 89 437 117 120 23 376 20 543 -6z
|
||||
m2913 -962 c50 -53 113 -138 198 -268 76 -116 60 -104 -41 32 -34 46 -63 81
|
||||
-66 79 -2 -2 2 -30 9 -63 9 -44 9 -86 1 -171 -6 -63 -13 -116 -16 -119 -3 -4
|
||||
-15 2 -26 12 -32 29 -34 12 -5 -57 50 -118 66 -160 62 -164 -2 -2 -29 34 -61
|
||||
81 -31 47 -61 83 -66 80 -6 -4 -7 -24 -4 -47 7 -39 6 -40 -14 -27 -12 7 -25
|
||||
10 -29 5 -17 -17 -4 -106 31 -210 20 -60 35 -111 33 -112 -2 -2 -30 43 -62 99
|
||||
-60 102 -173 233 -182 210 -2 -7 26 -82 62 -168 37 -85 65 -160 63 -166 -2 -6
|
||||
-43 66 -91 160 -63 121 -93 171 -106 171 -9 0 -37 -21 -61 -46 -55 -56 -56
|
||||
-56 -248 20 -78 31 -156 59 -172 63 l-30 6 24 -54 c13 -30 51 -114 85 -188 34
|
||||
-73 60 -135 58 -137 -2 -3 -21 24 -42 58 -56 91 -85 128 -102 128 -13 0 -14
|
||||
-8 -9 -42 l7 -42 -83 80 c-109 106 -163 133 -260 126 -35 -3 -38 0 -53 34 -9
|
||||
21 -13 44 -11 51 7 17 58 16 147 -2 101 -21 104 -17 92 106 -6 52 -7 98 -3
|
||||
102 4 5 25 -20 47 -55 22 -35 47 -68 54 -75 18 -14 278 -83 316 -83 23 0 42
|
||||
13 86 59 61 64 64 72 42 111 -19 33 -19 54 0 46 11 -4 23 6 37 30 l21 35 66
|
||||
-3 66 -3 -2 29 c-1 16 -25 63 -52 105 -28 41 -49 77 -47 78 2 2 47 -42 101
|
||||
-97 75 -76 105 -100 125 -100 14 0 35 -7 47 -15 20 -14 22 -14 27 2 3 10 6 72
|
||||
7 138 2 102 -1 128 -19 175 -12 30 -22 56 -22 58 0 9 40 -22 71 -55z m-2642
|
||||
-139 c29 -35 60 -64 67 -64 8 0 52 27 97 61 l82 60 47 -51 c59 -63 161 -218
|
||||
218 -327 23 -46 48 -83 55 -83 8 0 26 5 41 11 49 18 73 4 104 -64 33 -70 48
|
||||
-127 33 -127 -6 0 -32 9 -58 21 -28 12 -56 18 -71 15 -21 -6 -27 1 -55 56 -67
|
||||
132 -208 340 -244 361 -10 5 -19 -1 -29 -22 -29 -55 -35 -106 -20 -183 8 -40
|
||||
14 -74 14 -75 0 -1 -12 2 -26 8 l-27 10 7 -62 c6 -61 6 -62 -14 -44 -15 14
|
||||
-31 17 -72 13 -58 -6 -88 -32 -88 -76 l0 -26 -29 34 c-29 35 -30 35 -122 38
|
||||
-52 2 -99 8 -106 14 -15 12 -83 132 -83 146 0 6 23 39 50 73 56 70 68 99 52
|
||||
134 -12 27 -15 25 81 46 65 14 68 21 42 105 -26 85 -18 85 54 -2z m-397 -294
|
||||
c84 -126 196 -352 237 -475 33 -99 28 -115 -23 -66 -45 44 -55 29 -49 -77 5
|
||||
-95 -4 -97 -33 -7 -22 67 -52 125 -64 125 -6 0 -10 -39 -11 -92 0 -51 -4 -101
|
||||
-8 -111 -9 -23 -10 -22 -85 101 -32 50 -62 92 -68 92 -7 0 -9 -22 -4 -70 6
|
||||
-74 -3 -90 -24 -40 -21 50 -33 54 -87 30 -26 -11 -57 -30 -69 -41 -20 -19 -21
|
||||
-19 -75 10 -30 17 -91 41 -137 54 -46 13 -89 30 -97 38 -16 16 -45 142 -36
|
||||
157 3 5 58 33 121 61 l114 51 21 -26 21 -26 49 39 c51 40 69 66 80 116 5 23
|
||||
10 28 32 25 19 -2 28 -11 37 -38 37 -115 42 114 6 250 -45 171 -47 164 22 90
|
||||
33 -36 92 -112 130 -170z m-1789 73 c-3 -10 -32 -77 -63 -148 -48 -109 -137
|
||||
-340 -242 -628 -11 -32 -22 -56 -24 -54 -2 2 -9 32 -14 68 -24 141 -20 131
|
||||
-47 124 -13 -3 -50 -18 -81 -33 -43 -20 -62 -37 -79 -67 -32 -59 -40 -65 -88
|
||||
-65 -55 0 -56 6 -16 106 29 75 176 339 194 351 12 7 14 14 -47 -125 -25 -56
|
||||
-46 -113 -46 -127 0 -25 0 -25 35 -11 109 46 179 120 301 316 135 217 241 360
|
||||
217 293z m-869 -35 c-4 -7 -33 -49 -64 -93 -140 -200 -268 -431 -368 -665
|
||||
-114 -268 -153 -314 -74 -86 41 115 44 129 29 137 -28 16 -39 80 -22 128 27
|
||||
77 98 202 139 246 58 61 355 345 362 345 3 0 2 -6 -2 -12z m1415 -533 l1 -130
|
||||
-23 39 c-26 47 -41 51 -45 14 -4 -36 -18 -35 -37 1 -8 17 -19 32 -25 36 -5 3
|
||||
-60 -10 -122 -30 -76 -25 -130 -36 -165 -36 -29 1 -82 -5 -118 -14 -99 -23
|
||||
-109 -21 -149 29 -20 23 -36 50 -36 58 0 12 55 182 75 231 11 27 38 21 147
|
||||
-34 76 -38 108 -49 130 -45 21 4 28 2 28 -9 0 -10 11 -15 34 -15 45 0 153 34
|
||||
177 56 10 9 35 69 55 133 56 180 58 180 65 1 4 -85 7 -213 8 -285z m-1394 272
|
||||
c-31 -55 -32 -83 -2 -91 47 -12 65 -6 97 34 18 23 34 39 36 38 2 -2 -20 -48
|
||||
-48 -102 l-50 -99 33 7 c84 17 149 19 149 5 0 -8 -37 -104 -82 -213 -74 -177
|
||||
-83 -195 -86 -162 -4 50 -26 56 -66 17 -17 -17 -35 -31 -39 -31 -4 0 -7 18 -7
|
||||
40 0 28 -6 43 -18 51 -15 10 -18 21 -14 65 8 93 -18 69 -89 -80 -35 -74 -65
|
||||
-133 -67 -131 -3 2 5 35 17 74 11 38 21 74 21 80 0 6 -35 11 -87 13 l-88 3 3
|
||||
30 c4 42 162 364 187 381 11 8 44 14 76 14 55 0 59 2 93 42 20 23 36 45 36 50
|
||||
0 4 5 8 10 8 6 0 -1 -20 -15 -43z m1710 6 c120 -17 143 -19 164 -12 10 3 21
|
||||
-17 37 -67 24 -77 75 -306 69 -312 -2 -2 -21 14 -43 37 l-39 41 -145 0 c-128
|
||||
0 -148 2 -170 19 -16 13 -29 17 -39 10 -8 -5 -17 -9 -20 -9 -10 0 -66 178 -74
|
||||
232 -4 25 -4 56 0 68 7 21 11 22 79 16 39 -4 121 -14 181 -23z m-79 -988 c74
|
||||
-190 129 -303 247 -514 76 -136 79 -145 62 -157 -24 -17 -76 -18 -98 -1 -21
|
||||
16 -126 237 -165 347 -30 87 -143 516 -141 541 1 19 17 -19 95 -216z m3644 61
|
||||
c64 -163 185 -534 301 -926 66 -223 147 -490 179 -595 175 -566 250 -858 321
|
||||
-1240 22 -121 43 -231 46 -245 4 -18 3 -22 -6 -15 -6 6 -22 71 -36 145 -58
|
||||
313 -100 487 -200 820 -40 135 -94 317 -120 405 -161 550 -413 1387 -465 1540
|
||||
-55 165 -67 205 -56 194 2 -2 18 -39 36 -83z m-4395 -812 c61 -60 129 -118
|
||||
149 -128 46 -22 69 -19 238 25 70 18 130 30 133 27 3 -3 -19 -42 -49 -87 -53
|
||||
-78 -75 -125 -63 -137 14 -14 111 32 205 96 57 38 136 85 176 104 84 40 299
|
||||
116 327 116 12 0 37 -24 66 -65 25 -36 54 -67 62 -69 9 -2 178 -1 376 2 l360
|
||||
7 10 70 c10 68 10 69 22 40 7 -16 17 -49 23 -73 14 -53 18 -55 187 -82 178
|
||||
-28 191 -33 200 -78 12 -57 8 -612 -6 -742 -17 -170 -53 -395 -141 -880 -206
|
||||
-1142 -248 -1540 -194 -1863 8 -49 12 -97 9 -107 -8 -24 -195 -200 -213 -200
|
||||
-7 0 -21 14 -30 31 -140 256 -353 528 -467 594 -66 39 -95 39 -361 1 -137 -19
|
||||
-303 -40 -368 -47 -128 -12 -307 -7 -368 11 -54 16 -140 78 -185 132 -41 51
|
||||
-348 610 -438 801 -105 220 -178 478 -191 667 -7 97 11 328 25 343 5 5 23 -17
|
||||
41 -49 18 -32 72 -97 125 -148 54 -53 97 -104 101 -120 12 -50 -1 -119 -34
|
||||
-170 -22 -33 -32 -62 -32 -88 0 -45 31 -126 70 -182 23 -35 28 -50 23 -82 -3
|
||||
-24 4 -70 17 -119 18 -68 28 -87 68 -128 59 -60 118 -101 132 -92 6 4 10 18 8
|
||||
32 -3 22 3 28 48 44 28 11 59 28 70 40 l18 20 -107 -7 c-118 -8 -146 0 -166
|
||||
43 -17 37 -14 52 18 84 33 33 78 41 66 12 -14 -31 -16 -77 -5 -93 9 -13 14
|
||||
-11 36 14 21 24 25 37 21 68 -5 37 -4 38 36 49 56 15 192 6 242 -15 l40 -17
|
||||
-27 -20 c-66 -49 -1 -50 120 -2 80 31 92 40 92 64 0 33 -30 52 -72 45 -33 -5
|
||||
-51 1 -140 49 -226 121 -267 139 -327 139 -31 1 -68 -3 -83 -8 -24 -7 -32 -3
|
||||
-62 30 -62 67 -63 76 -30 145 53 111 38 151 -110 308 -88 93 -131 162 -142
|
||||
230 -11 67 0 84 99 164 142 113 222 232 261 384 46 178 -18 329 -188 441 -74
|
||||
48 -75 49 -51 60 35 16 31 28 -20 64 -31 22 -41 34 -32 40 21 14 168 59 252
|
||||
77 67 15 80 21 83 39 2 12 -33 92 -82 187 -100 193 -117 263 -35 144 28 -41
|
||||
102 -124 164 -185z m-430 -364 c-13 -21 19 -51 100 -97 78 -43 142 -93 119
|
||||
-93 -5 0 -34 7 -64 15 -69 19 -148 19 -180 0 -24 -14 -24 -14 21 -15 63 0 238
|
||||
-37 267 -56 24 -16 42 -52 42 -85 0 -17 -8 -14 -57 24 -92 71 -138 91 -213 90
|
||||
-36 0 -85 -8 -109 -17 -55 -20 -64 -14 -103 71 -37 82 -36 104 4 127 60 36
|
||||
190 63 173 36z m4328 -154 c76 -37 148 -103 127 -116 -27 -17 -107 -10 -174
|
||||
15 -91 34 -212 35 -310 1 -55 -19 -76 -22 -100 -14 -17 5 -37 12 -45 14 -17 6
|
||||
18 45 66 75 76 47 131 59 258 57 109 -3 125 -6 178 -32z m-4293 -282 c3 -28
|
||||
11 -58 17 -66 7 -8 12 -30 12 -49 -1 -40 10 -72 46 -128 33 -53 32 -67 -5 -80
|
||||
-78 -27 -118 37 -133 210 -10 105 -9 120 7 144 27 42 50 29 56 -31z m-53 -438
|
||||
c-4 -9 -11 -16 -17 -16 -11 0 -14 33 -3 44 11 10 26 -11 20 -28z m449 -922
|
||||
c60 -20 62 -23 44 -34 -23 -15 -104 -12 -126 5 -19 15 -19 15 0 30 24 18 22
|
||||
19 82 -1z m5843 -211 c82 -179 180 -472 218 -653 34 -160 38 -387 10 -517 -37
|
||||
-172 -107 -345 -191 -471 -81 -122 -215 -266 -232 -249 -2 2 10 37 27 78 188
|
||||
447 261 1077 185 1584 -15 98 -31 196 -35 217 -10 44 0 50 18 11z m-2689 -313
|
||||
c25 -333 24 -319 22 -342 -1 -10 -66 -56 -164 -115 -175 -106 -203 -121 -196
|
||||
-101 3 7 26 81 53 163 39 124 214 633 241 699 15 37 22 -12 44 -304z m110
|
||||
-832 c37 -172 37 -173 -56 -257 -80 -73 -143 -103 -230 -109 -105 -7 -132 9
|
||||
-183 108 -53 101 -53 132 -3 177 21 19 126 88 233 153 182 111 194 117 201 97
|
||||
3 -12 20 -88 38 -169z m-1046 -650 c67 -151 141 -236 331 -383 138 -106 239
|
||||
-173 309 -204 42 -18 47 -23 36 -36 -7 -9 -110 -81 -229 -160 -178 -118 -251
|
||||
-161 -416 -238 -110 -51 -250 -117 -312 -147 -62 -29 -118 -50 -126 -47 -7 3
|
||||
-24 34 -36 69 -28 77 -74 158 -252 445 -75 122 -140 231 -144 242 -6 19 -2 21
|
||||
42 21 63 0 171 24 230 50 35 15 99 72 238 209 182 180 271 261 285 261 4 0 24
|
||||
-37 44 -82z m-2387 -88 c-10 -81 -64 -284 -102 -378 -55 -136 -119 -238 -204
|
||||
-323 -108 -107 -162 -132 -306 -137 -109 -4 -111 -3 -175 30 -42 22 -76 48
|
||||
-98 78 l-35 45 114 7 c338 22 478 105 645 383 29 50 70 131 89 180 49 124 67
|
||||
165 73 165 3 0 2 -22 -1 -50z m1647 -1402 c-54 -78 -300 -358 -315 -358 -16 0
|
||||
-10 15 29 76 113 173 323 408 330 370 2 -10 -18 -49 -44 -88z"/>
|
||||
<path d="M3229 5845 c-108 -15 -150 -30 -198 -71 -49 -41 -111 -123 -111 -146
|
||||
0 -18 5 -20 40 -15 22 3 40 3 40 1 0 -2 -9 -26 -20 -53 -12 -32 -16 -52 -9
|
||||
-56 9 -6 7 -23 -8 -62 -3 -8 4 -13 20 -13 16 0 26 -7 30 -20 8 -30 33 -24 48
|
||||
12 15 37 122 148 142 148 12 0 11 -10 0 -56 -17 -66 -10 -118 17 -137 15 -11
|
||||
19 -21 14 -44 -5 -25 2 -39 44 -90 28 -33 66 -67 85 -76 39 -19 112 -22 152
|
||||
-7 39 15 108 74 140 121 27 38 28 41 13 72 -15 32 -15 34 8 54 13 12 24 33 24
|
||||
47 l0 25 38 -17 c58 -26 111 -62 122 -81 6 -13 3 -29 -11 -57 -98 -186 -404
|
||||
-264 -685 -174 -101 32 -143 37 -162 18 -21 -21 -13 -28 31 -28 49 0 82 -19
|
||||
91 -53 5 -20 11 -23 31 -19 14 2 31 0 38 -6 17 -14 145 -34 168 -27 11 4 26 1
|
||||
34 -5 9 -7 23 -9 37 -4 13 5 41 9 63 11 22 1 51 3 65 4 14 1 37 -1 52 -5 21
|
||||
-6 27 -4 33 13 5 18 14 21 55 21 43 0 49 3 52 23 3 19 10 22 51 25 49 3 53 7
|
||||
37 32 -11 17 4 30 38 30 15 0 22 6 22 19 0 14 16 27 55 45 40 18 56 31 61 51
|
||||
3 14 16 32 27 40 18 13 20 18 10 34 -11 17 -8 21 25 35 100 44 116 63 55 68
|
||||
-33 3 -38 6 -36 26 3 22 0 22 -45 16 -44 -6 -49 -4 -83 29 -58 56 -354 199
|
||||
-469 227 -116 28 -147 43 -70 36 30 -3 108 -23 173 -45 64 -22 117 -36 117
|
||||
-31 0 13 -23 24 -160 75 -142 53 -197 60 -331 40z"/>
|
||||
<path d="M3027 5303 c-3 -5 -2 -15 2 -22 7 -10 10 -10 16 -1 4 6 3 16 -3 22
|
||||
-5 5 -12 6 -15 1z"/>
|
||||
<path d="M2180 4462 c0 -11 136 -122 149 -122 19 0 12 46 -11 75 -12 15 -36
|
||||
34 -54 41 -37 15 -84 19 -84 6z"/>
|
||||
</g></g></svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
@@ -406,6 +406,31 @@ export async function* pullModel(
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModelRecommendation {
|
||||
model: string;
|
||||
description: string;
|
||||
context_length?: number;
|
||||
max_output_tokens?: number;
|
||||
vram_bytes?: number;
|
||||
}
|
||||
|
||||
export interface ModelRecommendationsResponse {
|
||||
recommendations: ModelRecommendation[];
|
||||
}
|
||||
|
||||
export async function getModelRecommendations(): Promise<ModelRecommendation[]> {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/experimental/model-recommendations`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch model recommendations: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
const data: ModelRecommendationsResponse = await response.json();
|
||||
return data.recommendations || [];
|
||||
}
|
||||
|
||||
export async function getInferenceCompute(): Promise<InferenceComputeResponse> {
|
||||
const response = await fetch(`${API_BASE}/api/v1/inference-compute`);
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -13,6 +13,30 @@ interface LaunchCommand {
|
||||
}
|
||||
|
||||
const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude Code",
|
||||
command: "ollama launch claude",
|
||||
description: "Anthropic's coding tool with subagents",
|
||||
icon: "/launch-icons/claude-code.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
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",
|
||||
name: "Hermes Agent",
|
||||
command: "ollama launch hermes",
|
||||
description: "Self-improving AI agent built by Nous Research",
|
||||
icon: "/launch-icons/hermes-agent.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "openclaw",
|
||||
name: "OpenClaw",
|
||||
@@ -21,12 +45,12 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||
icon: "/launch-icons/openclaw.svg",
|
||||
},
|
||||
{
|
||||
id: "claude",
|
||||
name: "Claude",
|
||||
command: "ollama launch claude",
|
||||
description: "Anthropic's coding tool with subagents",
|
||||
icon: "/launch-icons/claude.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
command: "ollama launch opencode",
|
||||
description: "Anomaly's open-source coding agent",
|
||||
icon: "/launch-icons/opencode.svg",
|
||||
iconClassName: "h-7 w-7 rounded",
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
@@ -38,12 +62,13 @@ const LAUNCH_COMMANDS: LaunchCommand[] = [
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
command: "ollama launch opencode",
|
||||
description: "Anomaly's open-source coding agent",
|
||||
icon: "/launch-icons/opencode.svg",
|
||||
iconClassName: "h-7 w-7 rounded",
|
||||
id: "copilot",
|
||||
name: "Copilot CLI",
|
||||
command: "ollama launch copilot",
|
||||
description: "GitHub's AI coding agent for the terminal",
|
||||
icon: "/launch-icons/copilot.svg",
|
||||
darkIcon: "/launch-icons/copilot-dark.svg",
|
||||
iconClassName: "h-7 w-7",
|
||||
},
|
||||
{
|
||||
id: "droid",
|
||||
|
||||
@@ -381,7 +381,7 @@ export const useSendMessage = (chatId: string) => {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
thinking: "",
|
||||
model: effectiveModel,
|
||||
model: effectiveModel.model,
|
||||
}),
|
||||
);
|
||||
lastMessage = newMessages[newMessages.length - 1];
|
||||
@@ -433,7 +433,7 @@ export const useSendMessage = (chatId: string) => {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
thinking: "",
|
||||
model: effectiveModel,
|
||||
model: effectiveModel.model,
|
||||
}),
|
||||
);
|
||||
lastMessage = newMessages[newMessages.length - 1];
|
||||
@@ -520,7 +520,7 @@ export const useSendMessage = (chatId: string) => {
|
||||
thinkingTimeStart:
|
||||
lastMessage.thinkingTimeStart || event.thinkingTimeStart,
|
||||
thinkingTimeEnd: event.thinkingTimeEnd,
|
||||
model: selectedModel,
|
||||
model: selectedModel.model,
|
||||
});
|
||||
newMessages[newMessages.length - 1] = updatedMessage;
|
||||
} else {
|
||||
@@ -533,7 +533,7 @@ export const useSendMessage = (chatId: string) => {
|
||||
tool_calls: event.toolCalls,
|
||||
thinkingTimeStart: event.thinkingTimeStart,
|
||||
thinkingTimeEnd: event.thinkingTimeEnd,
|
||||
model: selectedModel,
|
||||
model: selectedModel.model,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -699,7 +699,7 @@ export const useSendMessage = (chatId: string) => {
|
||||
queryClient.setQueryData(["chat", newId], {
|
||||
chat: new Chat({
|
||||
id: newId,
|
||||
model: effectiveModel,
|
||||
model: effectiveModel.model,
|
||||
messages: [
|
||||
new Message({
|
||||
role: "user",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getModelRecommendations } from "@/api";
|
||||
import type { ModelRecommendation } from "@/api";
|
||||
|
||||
export function useFeaturedModels() {
|
||||
return useQuery<ModelRecommendation[], Error>({
|
||||
queryKey: ["modelRecommendations"],
|
||||
queryFn: getModelRecommendations,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
}
|
||||
@@ -1,51 +1,49 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Model } from "@/gotypes";
|
||||
import { getModels } from "@/api";
|
||||
import { mergeModels } from "@/utils/mergeModels";
|
||||
import { useMemo } from "react";
|
||||
import { useCloudStatus } from "./useCloudStatus";
|
||||
import { useFeaturedModels } from "./useFeaturedModels";
|
||||
|
||||
export function useModels(searchQuery = "") {
|
||||
const { cloudDisabled } = useCloudStatus();
|
||||
const { data: recommendations, isLoading: recommendationsLoading } =
|
||||
useFeaturedModels();
|
||||
const localQuery = useQuery<Model[], Error>({
|
||||
queryKey: ["models", searchQuery],
|
||||
queryFn: () => getModels(searchQuery),
|
||||
gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
|
||||
gcTime: 10 * 60 * 1000,
|
||||
retry: 10,
|
||||
// exponential backoff, starting at 100ms and capping at 5s
|
||||
retryDelay: (attemptIndex) => Math.min(100 * 2 ** attemptIndex, 5000),
|
||||
refetchOnWindowFocus: true,
|
||||
refetchInterval: 30 * 1000, // Refetch every 30 seconds to keep models updated
|
||||
refetchInterval: 30 * 1000,
|
||||
refetchIntervalInBackground: true,
|
||||
});
|
||||
|
||||
const allModels = useMemo(() => {
|
||||
const models = mergeModels(localQuery.data || [], cloudDisabled);
|
||||
const local = localQuery.data || [];
|
||||
const featured = (recommendations || []).map((r) => r.model);
|
||||
const featuredSet = new Set(featured);
|
||||
|
||||
if (searchQuery && searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
const filteredModels = models.filter((model) =>
|
||||
model.model.toLowerCase().includes(query),
|
||||
);
|
||||
// Recommended models first (using the local copy when downloaded),
|
||||
// then everything else from /api/tags in tags order.
|
||||
const recommended = featured.map(
|
||||
(name) =>
|
||||
local.find((m) => m.model === name) || new Model({ model: name }),
|
||||
);
|
||||
const rest = local.filter((m) => !featuredSet.has(m.model));
|
||||
const merged = [...recommended, ...rest];
|
||||
|
||||
const seen = new Set<string>();
|
||||
return filteredModels.filter((model) => {
|
||||
const currentModel = model.model.toLowerCase();
|
||||
if (seen.has(currentModel)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(currentModel);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return models;
|
||||
}, [localQuery.data, searchQuery, cloudDisabled]);
|
||||
const visible = cloudDisabled
|
||||
? merged.filter((m) => !m.isCloud())
|
||||
: merged;
|
||||
return filterBySearch(visible, searchQuery);
|
||||
}, [localQuery.data, searchQuery, cloudDisabled, recommendations]);
|
||||
|
||||
return {
|
||||
...localQuery,
|
||||
data: allModels,
|
||||
isLoading: localQuery.isLoading,
|
||||
isLoading: localQuery.isLoading || recommendationsLoading,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,3 +51,16 @@ export function useRefetchModels() {
|
||||
const { refetch } = useModels();
|
||||
return refetch;
|
||||
}
|
||||
|
||||
function filterBySearch(models: Model[], query: string): Model[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return models;
|
||||
|
||||
const seen = new Set<string>();
|
||||
return models.filter((m) => {
|
||||
const name = m.model.toLowerCase();
|
||||
if (!name.includes(q) || seen.has(name)) return false;
|
||||
seen.add(name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useModels } from "./useModels";
|
||||
import { useChat } from "./useChats";
|
||||
import { useSettings } from "./useSettings.ts";
|
||||
import { Model } from "@/gotypes";
|
||||
import { FEATURED_MODELS } from "@/utils/mergeModels";
|
||||
import { getTotalVRAM } from "@/utils/vram.ts";
|
||||
import { getInferenceCompute } from "@/api";
|
||||
import { useCloudStatus } from "./useCloudStatus";
|
||||
@@ -92,9 +91,7 @@ export function useSelectedModel(currentChatId?: string, searchQuery?: string) {
|
||||
(settings.selectedModel &&
|
||||
new Model({
|
||||
model: settings.selectedModel,
|
||||
cloud: FEATURED_MODELS.some(
|
||||
(f) => f.endsWith("cloud") && f === settings.selectedModel,
|
||||
),
|
||||
cloud: settings.selectedModel.endsWith("cloud"),
|
||||
ollama_host: false,
|
||||
})) ||
|
||||
null
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { Model } from "@/gotypes";
|
||||
import { mergeModels, FEATURED_MODELS } from "@/utils/mergeModels";
|
||||
import "@/api";
|
||||
|
||||
describe("Model merging logic", () => {
|
||||
it("should handle cloud models with -cloud suffix", () => {
|
||||
const localModels: Model[] = [
|
||||
new Model({ model: "gpt-oss:120b-cloud" }),
|
||||
new Model({ model: "llama3:latest" }),
|
||||
new Model({ model: "mistral:latest" }),
|
||||
];
|
||||
|
||||
const merged = mergeModels(localModels);
|
||||
|
||||
// First verify cloud models are first and in FEATURED_MODELS order
|
||||
const cloudModels = FEATURED_MODELS.filter((m: string) =>
|
||||
m.endsWith("cloud"),
|
||||
);
|
||||
for (let i = 0; i < cloudModels.length; i++) {
|
||||
expect(merged[i].model).toBe(cloudModels[i]);
|
||||
expect(merged[i].isCloud()).toBe(true);
|
||||
}
|
||||
|
||||
// Then verify non-cloud featured models are next and in FEATURED_MODELS order
|
||||
const nonCloudFeatured = FEATURED_MODELS.filter(
|
||||
(m: string) => !m.endsWith("cloud"),
|
||||
);
|
||||
for (let i = 0; i < nonCloudFeatured.length; i++) {
|
||||
const model = merged[i + cloudModels.length];
|
||||
expect(model.model).toBe(nonCloudFeatured[i]);
|
||||
expect(model.isCloud()).toBe(false);
|
||||
}
|
||||
|
||||
// Verify local models are preserved and come after featured models
|
||||
const featuredCount = FEATURED_MODELS.length;
|
||||
expect(merged[featuredCount].model).toBe("llama3:latest");
|
||||
expect(merged[featuredCount + 1].model).toBe("mistral:latest");
|
||||
|
||||
// Length should be exactly featured models plus our local models
|
||||
expect(merged.length).toBe(FEATURED_MODELS.length + 2);
|
||||
});
|
||||
|
||||
it("should hide cloud models when cloud is disabled", () => {
|
||||
const localModels: Model[] = [
|
||||
new Model({ model: "gpt-oss:120b-cloud" }),
|
||||
new Model({ model: "llama3:latest" }),
|
||||
new Model({ model: "mistral:latest" }),
|
||||
];
|
||||
|
||||
const merged = mergeModels(localModels, true); // cloud disabled = true
|
||||
|
||||
// No cloud models should be present
|
||||
const cloudModels = merged.filter((m) => m.isCloud());
|
||||
expect(cloudModels.length).toBe(0);
|
||||
|
||||
// Should have non-cloud featured models
|
||||
const nonCloudFeatured = FEATURED_MODELS.filter(
|
||||
(m) => !m.endsWith("cloud"),
|
||||
);
|
||||
for (let i = 0; i < nonCloudFeatured.length; i++) {
|
||||
const model = merged[i];
|
||||
expect(model.model).toBe(nonCloudFeatured[i]);
|
||||
expect(model.isCloud()).toBe(false);
|
||||
}
|
||||
|
||||
// Local models should be preserved
|
||||
const featuredCount = nonCloudFeatured.length;
|
||||
expect(merged[featuredCount].model).toBe("llama3:latest");
|
||||
expect(merged[featuredCount + 1].model).toBe("mistral:latest");
|
||||
});
|
||||
|
||||
it("should handle empty input", () => {
|
||||
const merged = mergeModels([]);
|
||||
|
||||
// First verify cloud models are first and in FEATURED_MODELS order
|
||||
const cloudModels = FEATURED_MODELS.filter((m) => m.endsWith("cloud"));
|
||||
for (let i = 0; i < cloudModels.length; i++) {
|
||||
expect(merged[i].model).toBe(cloudModels[i]);
|
||||
expect(merged[i].isCloud()).toBe(true);
|
||||
}
|
||||
|
||||
// Then verify non-cloud featured models are next and in FEATURED_MODELS order
|
||||
const nonCloudFeatured = FEATURED_MODELS.filter(
|
||||
(m) => !m.endsWith("cloud"),
|
||||
);
|
||||
for (let i = 0; i < nonCloudFeatured.length; i++) {
|
||||
const model = merged[i + cloudModels.length];
|
||||
expect(model.model).toBe(nonCloudFeatured[i]);
|
||||
expect(model.isCloud()).toBe(false);
|
||||
}
|
||||
|
||||
// Length should be exactly FEATURED_MODELS length
|
||||
expect(merged.length).toBe(FEATURED_MODELS.length);
|
||||
});
|
||||
|
||||
it("should sort models correctly", () => {
|
||||
const localModels: Model[] = [
|
||||
new Model({ model: "zephyr:latest" }),
|
||||
new Model({ model: "alpha:latest" }),
|
||||
new Model({ model: "gpt-oss:120b-cloud" }),
|
||||
];
|
||||
|
||||
const merged = mergeModels(localModels);
|
||||
|
||||
// First verify cloud models are first and in FEATURED_MODELS order
|
||||
const cloudModels = FEATURED_MODELS.filter((m) => m.endsWith("cloud"));
|
||||
for (let i = 0; i < cloudModels.length; i++) {
|
||||
expect(merged[i].model).toBe(cloudModels[i]);
|
||||
expect(merged[i].isCloud()).toBe(true);
|
||||
}
|
||||
|
||||
// Then verify non-cloud featured models are next and in FEATURED_MODELS order
|
||||
const nonCloudFeatured = FEATURED_MODELS.filter(
|
||||
(m) => !m.endsWith("cloud"),
|
||||
);
|
||||
for (let i = 0; i < nonCloudFeatured.length; i++) {
|
||||
const model = merged[i + cloudModels.length];
|
||||
expect(model.model).toBe(nonCloudFeatured[i]);
|
||||
expect(model.isCloud()).toBe(false);
|
||||
}
|
||||
|
||||
// Non-featured local models should be at the end in alphabetical order
|
||||
const featuredCount = FEATURED_MODELS.length;
|
||||
expect(merged[featuredCount].model).toBe("alpha:latest");
|
||||
expect(merged[featuredCount + 1].model).toBe("zephyr:latest");
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Model } from "@/gotypes";
|
||||
|
||||
// Featured models list (in priority order)
|
||||
export const FEATURED_MODELS = [
|
||||
"kimi-k2.5:cloud",
|
||||
"glm-5:cloud",
|
||||
"minimax-m2.7:cloud",
|
||||
"gemma4:31b-cloud",
|
||||
"qwen3.5:397b-cloud",
|
||||
"gpt-oss:120b-cloud",
|
||||
"gpt-oss:20b-cloud",
|
||||
"deepseek-v3.1:671b-cloud",
|
||||
"gpt-oss:120b",
|
||||
"gpt-oss:20b",
|
||||
"gemma4:31b",
|
||||
"gemma4:26b",
|
||||
"gemma4:e4b",
|
||||
"gemma4:e2b",
|
||||
"deepseek-r1:8b",
|
||||
"qwen3-coder:30b",
|
||||
"qwen3-vl:30b",
|
||||
"qwen3-vl:8b",
|
||||
"qwen3-vl:4b",
|
||||
"qwen3.5:27b",
|
||||
"qwen3.5:9b",
|
||||
"qwen3.5:4b",
|
||||
];
|
||||
|
||||
function alphabeticalSort(a: Model, b: Model): number {
|
||||
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
|
||||
}
|
||||
|
||||
//Merges models, sorting cloud models first, then other models
|
||||
export function mergeModels(
|
||||
localModels: Model[],
|
||||
hideCloudModels: boolean = false,
|
||||
): Model[] {
|
||||
const allModels = (localModels || []).map((model) => model);
|
||||
|
||||
// 1. Get cloud models from local models and featured list
|
||||
const cloudModels = [...allModels.filter((m) => m.isCloud())];
|
||||
|
||||
// Add any cloud models from FEATURED_MODELS that aren't in local models
|
||||
FEATURED_MODELS.filter((f) => f.endsWith("cloud")).forEach((cloudModel) => {
|
||||
if (!cloudModels.some((m) => m.model === cloudModel)) {
|
||||
cloudModels.push(new Model({ model: cloudModel }));
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Get other featured models (non-cloud)
|
||||
const featuredModels = FEATURED_MODELS.filter(
|
||||
(f) => !f.endsWith("cloud"),
|
||||
).map((model) => {
|
||||
// Check if this model exists in local models
|
||||
const localMatch = allModels.find(
|
||||
(m) => m.model.toLowerCase() === model.toLowerCase(),
|
||||
);
|
||||
|
||||
if (localMatch) return localMatch;
|
||||
|
||||
return new Model({
|
||||
model,
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Get remaining local models that aren't featured and aren't cloud models
|
||||
const remainingModels = allModels.filter(
|
||||
(model) =>
|
||||
!model.isCloud() &&
|
||||
!FEATURED_MODELS.some(
|
||||
(f) => f.toLowerCase() === model.model.toLowerCase(),
|
||||
),
|
||||
);
|
||||
|
||||
cloudModels.sort((a, b) => {
|
||||
const aIndex = FEATURED_MODELS.indexOf(a.model);
|
||||
const bIndex = FEATURED_MODELS.indexOf(b.model);
|
||||
|
||||
// If both are featured, sort by their position in FEATURED_MODELS
|
||||
if (aIndex !== -1 && bIndex !== -1) {
|
||||
return aIndex - bIndex;
|
||||
}
|
||||
|
||||
// If only one is featured, featured model comes first
|
||||
if (aIndex !== -1 && bIndex === -1) return -1;
|
||||
if (aIndex === -1 && bIndex !== -1) return 1;
|
||||
|
||||
// If neither is featured, sort alphabetically
|
||||
return a.model.toLowerCase().localeCompare(b.model.toLowerCase());
|
||||
});
|
||||
|
||||
featuredModels.sort(
|
||||
(a, b) =>
|
||||
FEATURED_MODELS.indexOf(a.model) - FEATURED_MODELS.indexOf(b.model),
|
||||
);
|
||||
|
||||
remainingModels.sort(alphabeticalSort);
|
||||
|
||||
return hideCloudModels
|
||||
? [...featuredModels, ...remainingModels]
|
||||
: [...cloudModels, ...featuredModels, ...remainingModels];
|
||||
}
|
||||
@@ -302,6 +302,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("HEAD /api/version", ollamaProxy)
|
||||
mux.Handle("POST /api/me", ollamaProxy)
|
||||
mux.Handle("POST /api/signout", ollamaProxy)
|
||||
mux.Handle("GET /api/experimental/model-recommendations", ollamaProxy)
|
||||
|
||||
// React app - catch all non-API routes and serve the React app
|
||||
mux.Handle("GET /", s.appHandler())
|
||||
|
||||
+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,692 @@
|
||||
# Local Ollama superbuild targets.
|
||||
#
|
||||
# This file keeps the repository-root CMake project focused on orchestration:
|
||||
# it builds a runnable local Ollama payload by delegating llama.cpp work to the
|
||||
# llama/server CMake project and building the Go binary into a matching layout.
|
||||
|
||||
include(ExternalProject)
|
||||
|
||||
set(OLLAMA_LLAMA_BACKENDS "" CACHE STRING
|
||||
"Semicolon-separated llama-server GPU backends to build: cuda_v12;cuda_v13;rocm_v7_1;rocm_v7_2;vulkan;cuda_jetpack5;cuda_jetpack6")
|
||||
set(_ollama_mlx_backends_doc "Semicolon-separated MLX backends to build: cuda_v13;metal_v3;metal_v4")
|
||||
set(OLLAMA_VERSION "0.0.0" CACHE STRING "Ollama version embedded in the local Go binary")
|
||||
set(OLLAMA_PAYLOAD_INSTALL_PREFIX "${CMAKE_BINARY_DIR}" CACHE PATH
|
||||
"Build-time staging prefix for nested Ollama native payloads")
|
||||
|
||||
string(REGEX REPLACE "^v" "" OLLAMA_VERSION "${OLLAMA_VERSION}")
|
||||
|
||||
set(OLLAMA_NATIVE_CONFIG_ARG)
|
||||
if(CMAKE_CONFIGURATION_TYPES)
|
||||
set(OLLAMA_NATIVE_CONFIG_ARG --config Release)
|
||||
endif()
|
||||
|
||||
set(OLLAMA_NATIVE_EXTERNAL_OPTIONS)
|
||||
if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.28)
|
||||
list(APPEND OLLAMA_NATIVE_EXTERNAL_OPTIONS BUILD_JOB_SERVER_AWARE TRUE)
|
||||
endif()
|
||||
|
||||
function(ollama_check_metal_toolchain output_version)
|
||||
find_program(_ollama_xcrun xcrun)
|
||||
if(NOT _ollama_xcrun)
|
||||
message(FATAL_ERROR
|
||||
"MLX Metal requires Xcode command line tools. Install Xcode, run "
|
||||
"`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, "
|
||||
"then install the Metal toolchain with "
|
||||
"`xcodebuild -downloadComponent MetalToolchain`.")
|
||||
endif()
|
||||
|
||||
execute_process(
|
||||
COMMAND zsh "-c"
|
||||
"echo \"__METAL_VERSION__\" | \"${_ollama_xcrun}\" -sdk macosx metal -E -x metal -P - 2>/dev/null | tail -1 | tr -d '\n'"
|
||||
OUTPUT_VARIABLE _metal_version
|
||||
RESULT_VARIABLE _metal_result)
|
||||
if(NOT _metal_result EQUAL 0 OR NOT _metal_version MATCHES "^[0-9]+$")
|
||||
message(FATAL_ERROR
|
||||
"MLX Metal requires Xcode's Metal toolchain. Install Xcode, run "
|
||||
"`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`, "
|
||||
"then install the Metal toolchain with "
|
||||
"`xcodebuild -downloadComponent MetalToolchain`.")
|
||||
endif()
|
||||
|
||||
set(${output_version} "${_metal_version}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_macos_major_version output)
|
||||
execute_process(
|
||||
COMMAND sw_vers -productVersion
|
||||
OUTPUT_VARIABLE _macos_version
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
RESULT_VARIABLE _macos_result
|
||||
ERROR_QUIET)
|
||||
if(_macos_result EQUAL 0)
|
||||
string(REGEX MATCH "^[0-9]+" _macos_major "${_macos_version}")
|
||||
endif()
|
||||
set(${output} "${_macos_major}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_macos_sdk_major_version output)
|
||||
execute_process(
|
||||
COMMAND xcrun --sdk macosx --show-sdk-version
|
||||
OUTPUT_VARIABLE _sdk_version
|
||||
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||
RESULT_VARIABLE _sdk_result
|
||||
ERROR_QUIET)
|
||||
if(_sdk_result EQUAL 0)
|
||||
string(REGEX MATCH "^[0-9]+" _sdk_major "${_sdk_version}")
|
||||
endif()
|
||||
set(${output} "${_sdk_major}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_default_mlx_backends output)
|
||||
set(_backends "")
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
ollama_check_metal_toolchain(_metal_version)
|
||||
ollama_macos_major_version(_macos_major)
|
||||
ollama_macos_sdk_major_version(_sdk_major)
|
||||
if(_macos_major AND _sdk_major AND _macos_major GREATER_EQUAL 26 AND _sdk_major GREATER_EQUAL 26)
|
||||
set(_backends "metal_v4")
|
||||
else()
|
||||
set(_backends "metal_v3")
|
||||
endif()
|
||||
message(STATUS "Defaulting OLLAMA_MLX_BACKENDS=${_backends} for macOS arm64")
|
||||
endif()
|
||||
set(${output} "${_backends}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
if(NOT DEFINED OLLAMA_MLX_BACKENDS)
|
||||
ollama_default_mlx_backends(_ollama_default_mlx_backends)
|
||||
set(OLLAMA_MLX_BACKENDS "${_ollama_default_mlx_backends}" CACHE STRING "${_ollama_mlx_backends_doc}")
|
||||
else()
|
||||
set(OLLAMA_MLX_BACKENDS "${OLLAMA_MLX_BACKENDS}" CACHE STRING "${_ollama_mlx_backends_doc}")
|
||||
endif()
|
||||
|
||||
if(NOT OLLAMA_HAVE_LLAMA_SERVER)
|
||||
if(OLLAMA_LLAMA_BACKENDS)
|
||||
message(FATAL_ERROR "llama/server is required when OLLAMA_LLAMA_BACKENDS is set")
|
||||
endif()
|
||||
if(NOT OLLAMA_MLX_BACKENDS)
|
||||
message(FATAL_ERROR "llama/server is required for local Ollama builds")
|
||||
endif()
|
||||
else()
|
||||
file(READ "${CMAKE_SOURCE_DIR}/LLAMA_CPP_VERSION" OLLAMA_LLAMA_CPP_GIT_TAG)
|
||||
string(STRIP "${OLLAMA_LLAMA_CPP_GIT_TAG}" OLLAMA_LLAMA_CPP_GIT_TAG)
|
||||
include(${CMAKE_SOURCE_DIR}/llama/compat/compat.cmake)
|
||||
if(DEFINED FETCHCONTENT_SOURCE_DIR_LLAMA_CPP AND NOT "${FETCHCONTENT_SOURCE_DIR_LLAMA_CPP}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_LLAMA_CPP_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_LLAMA_CPP}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using llama.cpp source override: ${OLLAMA_LLAMA_CPP_SOURCE_DIR}")
|
||||
add_custom_target(ollama-llama-cpp-source)
|
||||
elseif(DEFINED ENV{OLLAMA_LLAMA_CPP_SOURCE})
|
||||
get_filename_component(OLLAMA_LLAMA_CPP_SOURCE_DIR
|
||||
"$ENV{OLLAMA_LLAMA_CPP_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using local llama.cpp source: ${OLLAMA_LLAMA_CPP_SOURCE_DIR}")
|
||||
add_custom_target(ollama-llama-cpp-source)
|
||||
else()
|
||||
set(OLLAMA_LLAMA_CPP_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/llama_cpp-src")
|
||||
ExternalProject_Add(ollama-llama-cpp-source
|
||||
GIT_REPOSITORY "https://github.com/ggml-org/llama.cpp.git"
|
||||
GIT_TAG ${OLLAMA_LLAMA_CPP_GIT_TAG}
|
||||
GIT_SHALLOW TRUE
|
||||
SOURCE_DIR ${OLLAMA_LLAMA_CPP_SOURCE_DIR}
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
PATCH_COMMAND ${OLLAMA_LLAMA_CPP_COMPAT_PATCH_COMMAND}
|
||||
USES_TERMINAL_DOWNLOAD TRUE
|
||||
USES_TERMINAL_PATCH TRUE)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(_mlx_source_targets)
|
||||
if(OLLAMA_MLX_BACKENDS)
|
||||
file(READ "${CMAKE_SOURCE_DIR}/MLX_VERSION" OLLAMA_MLX_GIT_TAG)
|
||||
string(STRIP "${OLLAMA_MLX_GIT_TAG}" OLLAMA_MLX_GIT_TAG)
|
||||
file(READ "${CMAKE_SOURCE_DIR}/MLX_C_VERSION" OLLAMA_MLX_C_GIT_TAG)
|
||||
string(STRIP "${OLLAMA_MLX_C_GIT_TAG}" OLLAMA_MLX_C_GIT_TAG)
|
||||
|
||||
if(DEFINED FETCHCONTENT_SOURCE_DIR_MLX AND NOT "${FETCHCONTENT_SOURCE_DIR_MLX}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_MLX_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_MLX}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using MLX source override: ${OLLAMA_MLX_SOURCE_DIR}")
|
||||
elseif(DEFINED ENV{OLLAMA_MLX_SOURCE})
|
||||
get_filename_component(OLLAMA_MLX_SOURCE_DIR
|
||||
"$ENV{OLLAMA_MLX_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using local MLX source: ${OLLAMA_MLX_SOURCE_DIR}")
|
||||
else()
|
||||
set(OLLAMA_MLX_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/mlx-src")
|
||||
ExternalProject_Add(ollama-mlx-source
|
||||
GIT_REPOSITORY "https://github.com/ml-explore/mlx.git"
|
||||
GIT_TAG ${OLLAMA_MLX_GIT_TAG}
|
||||
# MLX uses commit hashes while we track closely; switch to shallow when MLX pins move to tags.
|
||||
GIT_SHALLOW FALSE
|
||||
SOURCE_DIR ${OLLAMA_MLX_SOURCE_DIR}
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
USES_TERMINAL_DOWNLOAD TRUE)
|
||||
list(APPEND _mlx_source_targets ollama-mlx-source)
|
||||
endif()
|
||||
|
||||
if(DEFINED "FETCHCONTENT_SOURCE_DIR_MLX-C" AND NOT "${FETCHCONTENT_SOURCE_DIR_MLX-C}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_MLX_C_SOURCE_DIR
|
||||
"${FETCHCONTENT_SOURCE_DIR_MLX-C}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using MLX-C source override: ${OLLAMA_MLX_C_SOURCE_DIR}")
|
||||
elseif(DEFINED ENV{OLLAMA_MLX_C_SOURCE})
|
||||
get_filename_component(OLLAMA_MLX_C_SOURCE_DIR
|
||||
"$ENV{OLLAMA_MLX_C_SOURCE}" ABSOLUTE BASE_DIR "${CMAKE_SOURCE_DIR}")
|
||||
message(STATUS "Using local MLX-C source: ${OLLAMA_MLX_C_SOURCE_DIR}")
|
||||
else()
|
||||
set(OLLAMA_MLX_C_SOURCE_DIR "${CMAKE_BINARY_DIR}/_deps/mlx-c-src")
|
||||
ExternalProject_Add(ollama-mlx-c-source
|
||||
GIT_REPOSITORY "https://github.com/ml-explore/mlx-c.git"
|
||||
GIT_TAG ${OLLAMA_MLX_C_GIT_TAG}
|
||||
# MLX-C uses commit hashes while we track closely; switch to shallow when MLX-C pins move to tags.
|
||||
GIT_SHALLOW FALSE
|
||||
SOURCE_DIR ${OLLAMA_MLX_C_SOURCE_DIR}
|
||||
CONFIGURE_COMMAND ""
|
||||
BUILD_COMMAND ""
|
||||
INSTALL_COMMAND ""
|
||||
USES_TERMINAL_DOWNLOAD TRUE)
|
||||
list(APPEND _mlx_source_targets ollama-mlx-c-source)
|
||||
endif()
|
||||
add_custom_target(ollama-mlx-sources DEPENDS ${_mlx_source_targets})
|
||||
endif()
|
||||
|
||||
set(OLLAMA_NATIVE_BUILD_TOOL_COMMAND
|
||||
${CMAKE_COMMAND} --build <BINARY_DIR>)
|
||||
set(OLLAMA_NATIVE_BUILD_TARGET_ARG --target)
|
||||
if(CMAKE_GENERATOR MATCHES "Makefiles")
|
||||
set(OLLAMA_NATIVE_BUILD_TOOL_COMMAND
|
||||
"$(MAKE)" -C <BINARY_DIR>)
|
||||
set(OLLAMA_NATIVE_BUILD_TARGET_ARG)
|
||||
endif()
|
||||
|
||||
function(ollama_escape_cmake_list input output)
|
||||
string(REPLACE ";" "|" _escaped "${input}")
|
||||
set(${output} "${_escaped}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_collect_cache_args_with_prefix prefix output)
|
||||
get_cmake_property(_cache_variables CACHE_VARIABLES)
|
||||
list(SORT _cache_variables)
|
||||
|
||||
set(_args)
|
||||
foreach(_var IN LISTS _cache_variables)
|
||||
if(_var MATCHES "^${prefix}")
|
||||
ollama_escape_cmake_list("${${_var}}" _value)
|
||||
list(APPEND _args "-D${_var}=${_value}")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
set(${output} "${_args}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_append_cache_arg_if_set output name)
|
||||
if(DEFINED ${name} AND NOT "${${name}}" STREQUAL "")
|
||||
ollama_escape_cmake_list("${${name}}" _value)
|
||||
set(${output} ${${output}} "-D${name}=${_value}" PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(ollama_cache_arg_is_set name output)
|
||||
if(DEFINED ${name} AND NOT "${${name}}" STREQUAL "")
|
||||
set(${output} TRUE PARENT_SCOPE)
|
||||
else()
|
||||
set(${output} FALSE PARENT_SCOPE)
|
||||
endif()
|
||||
endfunction()
|
||||
|
||||
function(ollama_llama_cuda_preset backend output)
|
||||
ollama_cache_arg_is_set(CMAKE_CUDA_ARCHITECTURES _has_cuda_arch)
|
||||
if(_has_cuda_arch)
|
||||
set(_preset "llama_${backend}_user_arch")
|
||||
elseif(WIN32)
|
||||
set(_preset "llama_${backend}_windows")
|
||||
else()
|
||||
set(_preset "llama_${backend}_linux")
|
||||
endif()
|
||||
set(${output} "${_preset}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_mlx_cuda_preset output)
|
||||
ollama_cache_arg_is_set(MLX_CUDA_ARCHITECTURES _has_mlx_arch)
|
||||
ollama_cache_arg_is_set(CMAKE_CUDA_ARCHITECTURES _has_cuda_arch)
|
||||
if(_has_mlx_arch OR _has_cuda_arch)
|
||||
set(_preset "mlx_cuda_v13_user_arch")
|
||||
elseif(WIN32)
|
||||
set(_preset "mlx_cuda_v13_windows")
|
||||
else()
|
||||
set(_preset "mlx_cuda_v13_linux")
|
||||
endif()
|
||||
set(${output} "${_preset}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_rocm_preset backend output)
|
||||
ollama_cache_arg_is_set(AMDGPU_TARGETS _has_amdgpu_targets)
|
||||
ollama_cache_arg_is_set(CMAKE_HIP_ARCHITECTURES _has_hip_arch)
|
||||
if(_has_amdgpu_targets OR _has_hip_arch)
|
||||
if(backend STREQUAL "rocm_v7_1" AND NOT WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_1 is only supported for Windows ROCm builds")
|
||||
elseif(backend STREQUAL "rocm_v7_2" AND WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_2 is only supported for Linux ROCm builds")
|
||||
endif()
|
||||
elseif(backend STREQUAL "rocm_v7_1")
|
||||
if(NOT WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_1 is only supported for Windows ROCm builds")
|
||||
endif()
|
||||
set(_preset "${backend}_windows")
|
||||
elseif(backend STREQUAL "rocm_v7_2")
|
||||
if(WIN32)
|
||||
message(FATAL_ERROR "OLLAMA_LLAMA_BACKENDS=rocm_v7_2 is only supported for Linux ROCm builds")
|
||||
endif()
|
||||
set(_preset "${backend}_linux")
|
||||
else()
|
||||
message(FATAL_ERROR "Unknown ROCm backend '${backend}'")
|
||||
endif()
|
||||
if(_has_amdgpu_targets OR _has_hip_arch)
|
||||
set(_preset "${backend}_user_arch")
|
||||
endif()
|
||||
set(${output} "${_preset}" PARENT_SCOPE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_add_llama_server_build name)
|
||||
cmake_parse_arguments(ARG "" "PRESET;RUNNER_DIR" "TARGETS;CMAKE_ARGS" ${ARGN})
|
||||
if(NOT ARG_TARGETS)
|
||||
message(FATAL_ERROR "ollama_add_llama_server_build(${name}) requires TARGETS")
|
||||
endif()
|
||||
|
||||
if(WIN32 AND name STREQUAL "vulkan")
|
||||
# The Vulkan shader generator nests deeply enough to hit Windows MAX_PATH.
|
||||
set(_build_dir ${CMAKE_BINARY_DIR}/ls-vk)
|
||||
else()
|
||||
set(_build_dir ${CMAKE_BINARY_DIR}/llama-server-${name})
|
||||
endif()
|
||||
ollama_collect_cache_args_with_prefix("GGML_" _ggml_cache_args)
|
||||
ollama_collect_cache_args_with_prefix("LLAMA_" _llama_cache_args)
|
||||
set(_cmake_args
|
||||
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
|
||||
-DCMAKE_INSTALL_PREFIX=${OLLAMA_PAYLOAD_INSTALL_PREFIX}
|
||||
-DOLLAMA_LIB_DIR:STRING=${OLLAMA_LIB_DIR}
|
||||
-DOLLAMA_RUNNER_DIR=${ARG_RUNNER_DIR}
|
||||
-DFETCHCONTENT_SOURCE_DIR_LLAMA_CPP=${OLLAMA_LLAMA_CPP_SOURCE_DIR}
|
||||
-DOLLAMA_LLAMA_CPP_SKIP_COMPAT_PATCH=ON
|
||||
-DGGML_NATIVE=OFF
|
||||
-DGGML_OPENMP=OFF
|
||||
${ARG_CMAKE_ARGS}
|
||||
${_ggml_cache_args}
|
||||
${_llama_cache_args}
|
||||
)
|
||||
|
||||
if(APPLE)
|
||||
if(CMAKE_OSX_ARCHITECTURES)
|
||||
list(APPEND _cmake_args
|
||||
-DCMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES})
|
||||
endif()
|
||||
if(CMAKE_OSX_DEPLOYMENT_TARGET)
|
||||
list(APPEND _cmake_args
|
||||
-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET})
|
||||
endif()
|
||||
endif()
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/llama/server
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
if(ARG_PRESET)
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/llama/server
|
||||
--preset ${ARG_PRESET}
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
endif()
|
||||
ExternalProject_Add(ollama-llama-server-${name}
|
||||
SOURCE_DIR ${CMAKE_SOURCE_DIR}/llama/server
|
||||
BINARY_DIR ${_build_dir}
|
||||
CONFIGURE_COMMAND ${_configure_command}
|
||||
BUILD_COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
${OLLAMA_NATIVE_BUILD_TARGET_ARG} ${ARG_TARGETS}
|
||||
INSTALL_COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
--component llama-server
|
||||
DEPENDS ollama-llama-cpp-source
|
||||
LIST_SEPARATOR |
|
||||
# ExternalProject cannot reliably infer when nested FetchContent
|
||||
# sources, compat patches, or forwarded GGML/LLAMA cache settings need
|
||||
# a rebuild. Always entering the sub-build keeps direct `cmake --build`
|
||||
# iteration correct; the nested generator still performs incremental
|
||||
# compilation.
|
||||
BUILD_ALWAYS TRUE
|
||||
${OLLAMA_NATIVE_EXTERNAL_OPTIONS}
|
||||
USES_TERMINAL_CONFIGURE TRUE
|
||||
USES_TERMINAL_BUILD TRUE
|
||||
USES_TERMINAL_INSTALL TRUE)
|
||||
endfunction()
|
||||
|
||||
function(ollama_add_mlx_build name)
|
||||
cmake_parse_arguments(ARG "" "PRESET;RUNNER_DIR" "CMAKE_ARGS" ${ARGN})
|
||||
if(NOT ARG_RUNNER_DIR)
|
||||
message(FATAL_ERROR "ollama_add_mlx_build(${name}) requires RUNNER_DIR")
|
||||
endif()
|
||||
|
||||
set(_build_dir ${CMAKE_BINARY_DIR}/${ARG_RUNNER_DIR})
|
||||
ollama_collect_cache_args_with_prefix("MLX_" _mlx_cache_args)
|
||||
set(_cmake_args
|
||||
-DCMAKE_BUILD_TYPE=${CMAKE_BUILD_TYPE}
|
||||
-DCMAKE_INSTALL_PREFIX=${OLLAMA_PAYLOAD_INSTALL_PREFIX}
|
||||
-DOLLAMA_LIB_DIR:STRING=${OLLAMA_LIB_DIR}
|
||||
-DOLLAMA_RUNNER_DIR=${ARG_RUNNER_DIR}
|
||||
-DOLLAMA_SOURCE_DIR=${CMAKE_SOURCE_DIR}
|
||||
-DFETCHCONTENT_SOURCE_DIR_MLX=${OLLAMA_MLX_SOURCE_DIR}
|
||||
-DFETCHCONTENT_SOURCE_DIR_MLX-C=${OLLAMA_MLX_C_SOURCE_DIR}
|
||||
-DOLLAMA_MLX_GENERATE_WRAPPERS=OFF
|
||||
${ARG_CMAKE_ARGS}
|
||||
${_mlx_cache_args}
|
||||
)
|
||||
foreach(_arg IN ITEMS
|
||||
BLAS_INCLUDE_DIRS
|
||||
LAPACK_INCLUDE_DIRS
|
||||
CUDAToolkit_ROOT
|
||||
CUDNN_ROOT_DIR
|
||||
CUDNN_INCLUDE_PATH
|
||||
CUDNN_LIBRARY_PATH
|
||||
CMAKE_CUDA_COMPILER
|
||||
CMAKE_CUDA_HOST_COMPILER
|
||||
CMAKE_INCLUDE_PATH
|
||||
CMAKE_LIBRARY_PATH
|
||||
CMAKE_PREFIX_PATH)
|
||||
ollama_append_cache_arg_if_set(_cmake_args ${_arg})
|
||||
endforeach()
|
||||
|
||||
if(APPLE)
|
||||
if(CMAKE_OSX_ARCHITECTURES)
|
||||
list(APPEND _cmake_args
|
||||
-DCMAKE_OSX_ARCHITECTURES=${CMAKE_OSX_ARCHITECTURES})
|
||||
endif()
|
||||
endif()
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/cmake/mlx
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
if(ARG_PRESET)
|
||||
set(_configure_command ${CMAKE_COMMAND}
|
||||
-S ${CMAKE_SOURCE_DIR}/cmake/mlx
|
||||
--preset ${ARG_PRESET}
|
||||
-B <BINARY_DIR>
|
||||
${_cmake_args})
|
||||
endif()
|
||||
|
||||
ExternalProject_Add(ollama-mlx-${name}
|
||||
SOURCE_DIR ${CMAKE_SOURCE_DIR}/cmake/mlx
|
||||
BINARY_DIR ${_build_dir}
|
||||
CONFIGURE_COMMAND ${_configure_command}
|
||||
BUILD_COMMAND ${OLLAMA_NATIVE_BUILD_TOOL_COMMAND}
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlx
|
||||
${OLLAMA_NATIVE_BUILD_TARGET_ARG} mlxc
|
||||
INSTALL_COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
--component MLX
|
||||
COMMAND ${CMAKE_COMMAND} --install <BINARY_DIR>
|
||||
${OLLAMA_NATIVE_CONFIG_ARG}
|
||||
--component MLX_VENDOR
|
||||
DEPENDS ollama-mlx-sources
|
||||
LIST_SEPARATOR |
|
||||
BUILD_ALWAYS TRUE
|
||||
${OLLAMA_NATIVE_EXTERNAL_OPTIONS}
|
||||
USES_TERMINAL_CONFIGURE TRUE
|
||||
USES_TERMINAL_BUILD TRUE
|
||||
USES_TERMINAL_INSTALL TRUE)
|
||||
endfunction()
|
||||
|
||||
find_program(GO_EXECUTABLE go)
|
||||
|
||||
if(OLLAMA_MLX_BACKENDS)
|
||||
set(_mlx_c_headers_dir "${OLLAMA_MLX_C_SOURCE_DIR}/mlx/c")
|
||||
set(_mlx_c_headers_dest "${CMAKE_SOURCE_DIR}/x/mlxrunner/mlx/include/mlx/c")
|
||||
|
||||
if(GO_EXECUTABLE AND (NOT APPLE OR CMAKE_SYSTEM_PROCESSOR STREQUAL CMAKE_HOST_SYSTEM_PROCESSOR))
|
||||
add_custom_target(ollama-mlx-generate-wrappers
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DMLX_C_HEADERS_DIR=${_mlx_c_headers_dir}
|
||||
-DMLX_C_HEADERS_DEST=${_mlx_c_headers_dest}
|
||||
-P "${CMAKE_SOURCE_DIR}/cmake/vendor-mlx-c-headers.cmake"
|
||||
COMMAND ${CMAKE_COMMAND} -E env
|
||||
CC= CGO_CFLAGS= CGO_CXXFLAGS=
|
||||
${GO_EXECUTABLE} generate ./x/...
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
DEPENDS ollama-mlx-sources
|
||||
COMMENT "Regenerating MLX Go wrappers"
|
||||
VERBATIM)
|
||||
else()
|
||||
add_custom_target(ollama-mlx-generate-wrappers
|
||||
COMMAND ${CMAKE_COMMAND} -E echo
|
||||
"Cannot regenerate MLX wrappers while Go is unavailable or while cross-compiling"
|
||||
COMMAND ${CMAKE_COMMAND} -E false
|
||||
DEPENDS ollama-mlx-sources
|
||||
VERBATIM)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(OLLAMA_HAVE_LLAMA_SERVER)
|
||||
if(NOT OLLAMA_GO_OUTPUT)
|
||||
if(WIN32)
|
||||
set(OLLAMA_GO_OUTPUT ${CMAKE_SOURCE_DIR}/ollama.exe)
|
||||
else()
|
||||
set(OLLAMA_GO_OUTPUT ${CMAKE_SOURCE_DIR}/ollama)
|
||||
endif()
|
||||
endif()
|
||||
if(NOT IS_ABSOLUTE "${OLLAMA_GO_OUTPUT}")
|
||||
set(OLLAMA_GO_OUTPUT "${CMAKE_SOURCE_DIR}/${OLLAMA_GO_OUTPUT}")
|
||||
endif()
|
||||
get_filename_component(OLLAMA_GO_OUTPUT "${OLLAMA_GO_OUTPUT}" ABSOLUTE)
|
||||
set(OLLAMA_GO_OUTPUT "${OLLAMA_GO_OUTPUT}" CACHE FILEPATH "Output path for the local Ollama Go binary")
|
||||
get_filename_component(OLLAMA_GO_OUTPUT_DIR "${OLLAMA_GO_OUTPUT}" DIRECTORY)
|
||||
|
||||
set(OLLAMA_GO_LDFLAGS
|
||||
"-s -w -X=github.com/ollama/ollama/version.Version=${OLLAMA_VERSION} -X=github.com/ollama/ollama/server.mode=release")
|
||||
if(GO_EXECUTABLE)
|
||||
add_custom_target(ollama-go ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E make_directory "${OLLAMA_GO_OUTPUT_DIR}"
|
||||
COMMAND ${CMAKE_COMMAND} -E env CGO_ENABLED=1
|
||||
${GO_EXECUTABLE} build -trimpath -ldflags "${OLLAMA_GO_LDFLAGS}" -o "${OLLAMA_GO_OUTPUT}" .
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
|
||||
BYPRODUCTS ${OLLAMA_GO_OUTPUT}
|
||||
COMMENT "Building Ollama Go binary"
|
||||
VERBATIM)
|
||||
else()
|
||||
add_custom_target(ollama-go ALL
|
||||
COMMAND ${CMAKE_COMMAND} -E echo
|
||||
"Go executable not found. Install Go or set GO_EXECUTABLE to build the local Ollama binary."
|
||||
COMMAND ${CMAKE_COMMAND} -E false
|
||||
COMMENT "Building Ollama Go binary"
|
||||
VERBATIM)
|
||||
endif()
|
||||
|
||||
set(_cpu_args)
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
list(APPEND _cpu_args
|
||||
-DBUILD_SHARED_LIBS=OFF
|
||||
-DGGML_BACKEND_DL=OFF
|
||||
-DGGML_METAL=ON
|
||||
-DGGML_METAL_EMBED_LIBRARY=ON)
|
||||
else()
|
||||
list(APPEND _cpu_args
|
||||
-DBUILD_SHARED_LIBS=ON
|
||||
-DGGML_BACKEND_DL=ON
|
||||
-DGGML_CPU_ALL_VARIANTS=ON)
|
||||
if(WIN32)
|
||||
list(APPEND _cpu_args -DGGML_OPENMP=ON)
|
||||
endif()
|
||||
if(APPLE)
|
||||
list(APPEND _cpu_args -DGGML_METAL=OFF)
|
||||
endif()
|
||||
endif()
|
||||
|
||||
ollama_add_llama_server_build(local
|
||||
RUNNER_DIR ""
|
||||
TARGETS llama-server llama-quantize
|
||||
CMAKE_ARGS ${_cpu_args})
|
||||
|
||||
add_custom_target(ollama-local ALL
|
||||
DEPENDS ollama-go ollama-llama-server-local
|
||||
COMMENT "Building local Ollama payload")
|
||||
|
||||
install(PROGRAMS "${OLLAMA_GO_OUTPUT}"
|
||||
DESTINATION "${CMAKE_INSTALL_BINDIR}"
|
||||
COMPONENT ollama-local)
|
||||
endif()
|
||||
|
||||
set(_backend_targets)
|
||||
if(OLLAMA_HAVE_LLAMA_SERVER)
|
||||
foreach(_backend IN LISTS OLLAMA_LLAMA_BACKENDS)
|
||||
if(_backend STREQUAL "cuda_v12")
|
||||
ollama_llama_cuda_preset(${_backend} _cuda_preset)
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "cuda_v13")
|
||||
ollama_llama_cuda_preset(${_backend} _cuda_preset)
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "rocm_v7_1" OR _backend STREQUAL "rocm_v7_2")
|
||||
# ROCm 7.1 and 7.2 currently share build settings. Keep the backend
|
||||
# names versioned so future packaging can install side-by-side ROCm
|
||||
# payloads without changing the superbuild interface.
|
||||
ollama_rocm_preset(${_backend} _rocm_preset)
|
||||
set(_rocm_args
|
||||
-DBUILD_SHARED_LIBS=ON
|
||||
-DGGML_BACKEND_DL=ON
|
||||
-DGGML_HIP=ON
|
||||
-DCMAKE_HIP_PLATFORM=amd
|
||||
-DOLLAMA_GPU_BACKEND=hip)
|
||||
ollama_append_cache_arg_if_set(_rocm_args AMDGPU_TARGETS)
|
||||
ollama_append_cache_arg_if_set(_rocm_args CMAKE_HIP_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_rocm_args CMAKE_HIP_FLAGS)
|
||||
ollama_append_cache_arg_if_set(_rocm_args GGML_CUDA_NO_PEER_COPY)
|
||||
ollama_append_cache_arg_if_set(_rocm_args CMAKE_PREFIX_PATH)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_rocm_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-hip
|
||||
CMAKE_ARGS ${_rocm_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "vulkan")
|
||||
ollama_add_llama_server_build(vulkan
|
||||
RUNNER_DIR vulkan
|
||||
TARGETS ggml-vulkan
|
||||
CMAKE_ARGS
|
||||
-DBUILD_SHARED_LIBS=ON
|
||||
-DGGML_BACKEND_DL=ON
|
||||
-DGGML_VULKAN=ON
|
||||
-DOLLAMA_GPU_BACKEND=vulkan)
|
||||
list(APPEND _backend_targets ollama-llama-server-vulkan)
|
||||
elseif(_backend STREQUAL "cuda_jetpack5")
|
||||
if(CMAKE_CUDA_ARCHITECTURES)
|
||||
set(_cuda_preset llama_cuda_jetpack5_user_arch)
|
||||
else()
|
||||
set(_cuda_preset llama_cuda_jetpack5)
|
||||
endif()
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
elseif(_backend STREQUAL "cuda_jetpack6")
|
||||
if(CMAKE_CUDA_ARCHITECTURES)
|
||||
set(_cuda_preset llama_cuda_jetpack6_user_arch)
|
||||
else()
|
||||
set(_cuda_preset llama_cuda_jetpack6)
|
||||
endif()
|
||||
set(_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_llama_server_build(${_backend}
|
||||
PRESET ${_cuda_preset}
|
||||
RUNNER_DIR ${_backend}
|
||||
TARGETS ggml-cuda
|
||||
CMAKE_ARGS ${_cuda_args})
|
||||
list(APPEND _backend_targets ollama-llama-server-${_backend})
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"Unknown OLLAMA_LLAMA_BACKENDS entry '${_backend}'")
|
||||
endif()
|
||||
endforeach()
|
||||
endif()
|
||||
|
||||
if(_backend_targets)
|
||||
add_custom_target(ollama-llama-server-backends ALL
|
||||
DEPENDS ${_backend_targets}
|
||||
COMMENT "Building llama-server GPU backends")
|
||||
endif()
|
||||
|
||||
set(_mlx_targets)
|
||||
foreach(_backend IN LISTS OLLAMA_MLX_BACKENDS)
|
||||
if(_backend STREQUAL "cuda_v13")
|
||||
ollama_mlx_cuda_preset(_mlx_cuda_preset)
|
||||
set(_mlx_cuda_args)
|
||||
ollama_append_cache_arg_if_set(_mlx_cuda_args CMAKE_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_mlx_cuda_args MLX_CUDA_ARCHITECTURES)
|
||||
ollama_append_cache_arg_if_set(_mlx_cuda_args CMAKE_CUDA_FLAGS)
|
||||
ollama_add_mlx_build(cuda_v13
|
||||
PRESET ${_mlx_cuda_preset}
|
||||
RUNNER_DIR mlx_cuda_v13
|
||||
CMAKE_ARGS ${_mlx_cuda_args})
|
||||
list(APPEND _mlx_targets ollama-mlx-cuda_v13)
|
||||
elseif(_backend STREQUAL "metal_v3")
|
||||
if(NOT APPLE)
|
||||
message(FATAL_ERROR "OLLAMA_MLX_BACKENDS=metal_v3 is only supported on macOS")
|
||||
endif()
|
||||
ollama_check_metal_toolchain(_metal_version)
|
||||
ollama_add_mlx_build(metal_v3
|
||||
PRESET mlx_metal_v3
|
||||
RUNNER_DIR mlx_metal_v3)
|
||||
list(APPEND _mlx_targets ollama-mlx-metal_v3)
|
||||
elseif(_backend STREQUAL "metal_v4")
|
||||
if(NOT APPLE)
|
||||
message(FATAL_ERROR "OLLAMA_MLX_BACKENDS=metal_v4 is only supported on macOS")
|
||||
endif()
|
||||
ollama_check_metal_toolchain(_metal_version)
|
||||
ollama_macos_sdk_major_version(_ollama_mlx_sdk_major)
|
||||
if(_ollama_mlx_sdk_major AND _ollama_mlx_sdk_major GREATER_EQUAL 26)
|
||||
ollama_add_mlx_build(metal_v4
|
||||
PRESET mlx_metal_v4
|
||||
RUNNER_DIR mlx_metal_v4)
|
||||
list(APPEND _mlx_targets ollama-mlx-metal_v4)
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"OLLAMA_MLX_BACKENDS=metal_v4 requires the macOS 26 SDK. "
|
||||
"Install a newer Xcode or use OLLAMA_MLX_BACKENDS=metal_v3.")
|
||||
endif()
|
||||
else()
|
||||
message(FATAL_ERROR
|
||||
"Unknown OLLAMA_MLX_BACKENDS entry '${_backend}'")
|
||||
endif()
|
||||
endforeach()
|
||||
|
||||
if(_mlx_targets)
|
||||
add_custom_target(ollama-mlx-backends ALL
|
||||
DEPENDS ${_mlx_targets}
|
||||
COMMENT "Building MLX backends")
|
||||
endif()
|
||||
|
||||
install(DIRECTORY "${OLLAMA_PAYLOAD_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}/"
|
||||
DESTINATION "${OLLAMA_LIB_DIR}"
|
||||
COMPONENT ollama-local
|
||||
USE_SOURCE_PERMISSIONS)
|
||||
@@ -0,0 +1,235 @@
|
||||
cmake_minimum_required(VERSION 3.24)
|
||||
|
||||
project(OllamaMLX C CXX)
|
||||
|
||||
include(CheckLanguage)
|
||||
include(GNUInstallDirs)
|
||||
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
if(NOT CMAKE_CONFIGURATION_TYPES AND NOT CMAKE_BUILD_TYPE)
|
||||
set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED BUILD_SHARED_LIBS)
|
||||
set(BUILD_SHARED_LIBS ON)
|
||||
endif()
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_CXX_EXTENSIONS ON)
|
||||
|
||||
if(APPLE)
|
||||
set(CMAKE_BUILD_RPATH "@loader_path")
|
||||
set(CMAKE_INSTALL_RPATH "@loader_path")
|
||||
set(CMAKE_BUILD_WITH_INSTALL_RPATH ON)
|
||||
endif()
|
||||
|
||||
if(NOT DEFINED OLLAMA_SOURCE_DIR OR "${OLLAMA_SOURCE_DIR}" STREQUAL "")
|
||||
get_filename_component(OLLAMA_SOURCE_DIR "${CMAKE_CURRENT_LIST_DIR}/../.." ABSOLUTE)
|
||||
endif()
|
||||
get_filename_component(OLLAMA_SOURCE_DIR "${OLLAMA_SOURCE_DIR}" ABSOLUTE BASE_DIR "${CMAKE_CURRENT_LIST_DIR}")
|
||||
set(OLLAMA_SOURCE_DIR "${OLLAMA_SOURCE_DIR}" CACHE PATH "Ollama repository root")
|
||||
|
||||
set(OLLAMA_LIB_DIR "lib/ollama" CACHE STRING "Install destination for Ollama runtime payloads")
|
||||
set(OLLAMA_RUNNER_DIR "" CACHE STRING "Ollama runtime payload subdirectory")
|
||||
set(OLLAMA_BUILD_DIR ${CMAKE_BINARY_DIR}/lib/ollama)
|
||||
set(OLLAMA_INSTALL_DIR ${OLLAMA_LIB_DIR}/${OLLAMA_RUNNER_DIR})
|
||||
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_DEBUG ${OLLAMA_BUILD_DIR})
|
||||
set(CMAKE_LIBRARY_OUTPUT_DIRECTORY_RELEASE ${OLLAMA_BUILD_DIR})
|
||||
|
||||
if(MLX_CUDA_ARCHITECTURES OR CMAKE_CUDA_ARCHITECTURES)
|
||||
check_language(CUDA)
|
||||
endif()
|
||||
|
||||
option(OLLAMA_MLX_GENERATE_WRAPPERS "Regenerate MLX Go wrappers" OFF)
|
||||
|
||||
message(STATUS "Setting up MLX (this takes a while...)")
|
||||
add_subdirectory(${OLLAMA_SOURCE_DIR}/x/imagegen/mlx ${CMAKE_BINARY_DIR}/x/imagegen/mlx)
|
||||
|
||||
# Find CUDA toolkit if MLX is built with CUDA support.
|
||||
find_package(CUDAToolkit)
|
||||
|
||||
# Build list of directories for runtime dependency resolution.
|
||||
set(MLX_RUNTIME_DIRS ${CUDAToolkit_BIN_DIR} ${CUDAToolkit_BIN_DIR}/x64 ${CUDAToolkit_LIBRARY_DIR})
|
||||
# Add cuDNN bin paths for DLLs (Windows MLX CUDA builds).
|
||||
# CUDNN_ROOT_DIR is the standard CMake variable for cuDNN location.
|
||||
if(CUDNN_ROOT_DIR)
|
||||
set(_cudnn_root "${CUDNN_ROOT_DIR}")
|
||||
elseif(DEFINED ENV{CUDNN_ROOT_DIR})
|
||||
set(_cudnn_root "$ENV{CUDNN_ROOT_DIR}")
|
||||
endif()
|
||||
if(_cudnn_root)
|
||||
# cuDNN 9.x has versioned subdirectories under bin/ (e.g., bin/13.0/).
|
||||
file(GLOB CUDNN_BIN_SUBDIRS "${_cudnn_root}/bin/*")
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CUDNN_BIN_SUBDIRS})
|
||||
endif()
|
||||
# Add build output directory and MLX dependency build directories.
|
||||
list(APPEND MLX_RUNTIME_DIRS ${OLLAMA_BUILD_DIR})
|
||||
# OpenBLAS DLL location (pre-built zip extracts into openblas-src/bin/).
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/openblas-src/bin)
|
||||
# NCCL: on Linux, if real NCCL is found, cmake bundles libnccl.so via the
|
||||
# regex below. If NCCL is not found, MLX links a static stub (OBJECT lib)
|
||||
# so there is no runtime dependency. This path covers the stub build dir
|
||||
# for windows so we include the DLL in our dependencies.
|
||||
list(APPEND MLX_RUNTIME_DIRS ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/distributed/nccl/nccl_stub-prefix/src/nccl_stub-build/Release)
|
||||
|
||||
# Base regexes for runtime dependencies (cross-platform).
|
||||
set(MLX_INCLUDE_REGEXES cublas cublasLt cudart cufft nvrtc nvrtc-builtins cudnn nccl openblas gfortran)
|
||||
# On Windows, also include dl.dll (dlfcn-win32 POSIX emulation layer).
|
||||
if(WIN32)
|
||||
list(APPEND MLX_INCLUDE_REGEXES "^dl\\.dll$")
|
||||
endif()
|
||||
|
||||
# Keep mlx/mlxc targets separate from runtime dependencies so --strip only
|
||||
# applies to the binaries we build, not vendor DLLs/libs.
|
||||
install(TARGETS mlx mlxc
|
||||
RUNTIME_DEPENDENCY_SET mlx_runtime_deps
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
install(RUNTIME_DEPENDENCY_SET mlx_runtime_deps
|
||||
DIRECTORIES ${MLX_RUNTIME_DIRS}
|
||||
PRE_INCLUDE_REGEXES ${MLX_INCLUDE_REGEXES}
|
||||
PRE_EXCLUDE_REGEXES ".*"
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX_VENDOR
|
||||
)
|
||||
|
||||
if(TARGET jaccl)
|
||||
install(TARGETS jaccl
|
||||
RUNTIME DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
LIBRARY DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
FRAMEWORK DESTINATION ${OLLAMA_INSTALL_DIR} COMPONENT MLX
|
||||
)
|
||||
endif()
|
||||
|
||||
# Install the Metal library for macOS arm64 (must be colocated with the binary).
|
||||
# Metal backend is only built for arm64, not x86_64.
|
||||
if(APPLE AND CMAKE_SYSTEM_PROCESSOR STREQUAL "arm64")
|
||||
install(FILES ${CMAKE_BINARY_DIR}/_deps/mlx-build/mlx/backend/metal/kernels/mlx.metallib
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install headers for NVRTC JIT compilation at runtime.
|
||||
# MLX's own install rules use the default component so they get skipped by
|
||||
# --component MLX. Headers are installed alongside libmlx in OLLAMA_INSTALL_DIR.
|
||||
#
|
||||
# Layout:
|
||||
# ${OLLAMA_INSTALL_DIR}/include/cccl/{cuda,nv}/ - CCCL headers
|
||||
# ${OLLAMA_INSTALL_DIR}/include/*.h - CUDA toolkit headers
|
||||
#
|
||||
# MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir()[.parent_path()] / "include" / "cccl"
|
||||
# On Linux, MLX's jit_module.cpp resolves CCCL via
|
||||
# current_binary_dir().parent_path() / "include" / "cccl", so we create a
|
||||
# symlink from lib/ollama/include -> ${OLLAMA_RUNNER_DIR}/include.
|
||||
# This will need refinement if we add multiple CUDA versions for MLX in the future.
|
||||
# CUDA runtime headers are found via CUDA_PATH env var (set by mlxrunner).
|
||||
if(EXISTS ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/cuda
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
install(DIRECTORY ${CMAKE_BINARY_DIR}/_deps/cccl-src/include/nv
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}/include/cccl
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Install minimal CUDA toolkit headers needed by MLX JIT kernels.
|
||||
# These are the transitive closure of includes from mlx/backend/cuda/device/*.cuh.
|
||||
# The Go mlxrunner sets CUDA_PATH to OLLAMA_INSTALL_DIR so MLX finds them at
|
||||
# $CUDA_PATH/include/*.h via NVRTC --include-path.
|
||||
if(CUDAToolkit_FOUND)
|
||||
# CUDAToolkit_INCLUDE_DIRS may be a semicolon-separated list
|
||||
# (e.g. ".../include;.../include/cccl"). Find the entry that
|
||||
# contains the CUDA runtime headers we need.
|
||||
set(_cuda_inc "")
|
||||
foreach(_dir ${CUDAToolkit_INCLUDE_DIRS})
|
||||
if(EXISTS "${_dir}/cuda_runtime_api.h")
|
||||
set(_cuda_inc "${_dir}")
|
||||
break()
|
||||
endif()
|
||||
endforeach()
|
||||
if(NOT _cuda_inc)
|
||||
message(WARNING "Could not find cuda_runtime_api.h in CUDAToolkit_INCLUDE_DIRS: ${CUDAToolkit_INCLUDE_DIRS}")
|
||||
else()
|
||||
set(_dst "${OLLAMA_INSTALL_DIR}/include")
|
||||
set(_MLX_JIT_CUDA_HEADERS
|
||||
builtin_types.h
|
||||
cooperative_groups.h
|
||||
cuda_bf16.h
|
||||
cuda_bf16.hpp
|
||||
cuda_device_runtime_api.h
|
||||
cuda_fp16.h
|
||||
cuda_fp16.hpp
|
||||
cuda_fp8.h
|
||||
cuda_fp8.hpp
|
||||
cuda_runtime_api.h
|
||||
device_types.h
|
||||
driver_types.h
|
||||
math_constants.h
|
||||
surface_types.h
|
||||
texture_types.h
|
||||
vector_functions.h
|
||||
vector_functions.hpp
|
||||
vector_types.h
|
||||
)
|
||||
foreach(_hdr ${_MLX_JIT_CUDA_HEADERS})
|
||||
install(FILES "${_cuda_inc}/${_hdr}"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX)
|
||||
endforeach()
|
||||
# Subdirectory headers.
|
||||
install(DIRECTORY "${_cuda_inc}/cooperative_groups"
|
||||
DESTINATION ${_dst}
|
||||
COMPONENT MLX
|
||||
FILES_MATCHING PATTERN "*.h")
|
||||
install(FILES "${_cuda_inc}/crt/host_defines.h"
|
||||
DESTINATION "${_dst}/crt"
|
||||
COMPONENT MLX)
|
||||
if(NOT WIN32 AND NOT APPLE)
|
||||
install(CODE "
|
||||
set(_link \"${CMAKE_INSTALL_PREFIX}/${OLLAMA_LIB_DIR}/include\")
|
||||
set(_target \"${OLLAMA_RUNNER_DIR}/include\")
|
||||
if(NOT EXISTS \${_link})
|
||||
execute_process(COMMAND \${CMAKE_COMMAND} -E create_symlink \${_target} \${_link})
|
||||
endif()
|
||||
" COMPONENT MLX)
|
||||
endif()
|
||||
endif()
|
||||
endif()
|
||||
|
||||
# On Windows, explicitly install dl.dll (dlfcn-win32 POSIX dlopen emulation).
|
||||
# RUNTIME_DEPENDENCIES auto-excludes it via POST_EXCLUDE_FILES_STRICT because
|
||||
# dlfcn-win32 is a known CMake target with its own install rules (which install
|
||||
# to the wrong destination). We must install it explicitly here.
|
||||
if(WIN32)
|
||||
install(FILES ${OLLAMA_BUILD_DIR}/dl.dll
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX)
|
||||
endif()
|
||||
|
||||
# Manually install CUDA runtime libraries that MLX loads via dlopen
|
||||
# (not detected by RUNTIME_DEPENDENCIES since they aren't link-time deps).
|
||||
if(CUDAToolkit_FOUND)
|
||||
file(GLOB MLX_CUDA_LIBS
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudart.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublas.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcublasLt.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libnvrtc-builtins.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcufft.so*"
|
||||
"${CUDAToolkit_LIBRARY_DIR}/libcudnn.so*")
|
||||
if(MLX_CUDA_LIBS)
|
||||
install(FILES ${MLX_CUDA_LIBS}
|
||||
DESTINATION ${OLLAMA_INSTALL_DIR}
|
||||
COMPONENT MLX_VENDOR)
|
||||
endif()
|
||||
endif()
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"version": 3,
|
||||
"configurePresets": [
|
||||
{
|
||||
"name": "default",
|
||||
"binaryDir": "${sourceDir}/../../build/mlx",
|
||||
"installDir": "${sourceDir}/../../dist",
|
||||
"cacheVariables": {
|
||||
"CMAKE_BUILD_TYPE": "Release",
|
||||
"CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded",
|
||||
"OLLAMA_SOURCE_DIR": "${sourceDir}/../.."
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_base",
|
||||
"hidden": true,
|
||||
"inherits": [ "default" ],
|
||||
"cacheVariables": {
|
||||
"CMAKE_CUDA_FLAGS": "-t 2",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_cuda_v13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_linux",
|
||||
"inherits": [ "mlx_cuda_v13_base" ],
|
||||
"binaryDir": "${sourceDir}/../../build/mlx_cuda_v13",
|
||||
"cacheVariables": {
|
||||
"MLX_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_windows",
|
||||
"inherits": [ "mlx_cuda_v13_base" ],
|
||||
"binaryDir": "${sourceDir}/../../build/mlx_cuda_v13",
|
||||
"cacheVariables": {
|
||||
"MLX_CUDA_ARCHITECTURES": "75-virtual;80-virtual;86-virtual;89-virtual;90-virtual;90a-virtual;100-virtual;103-virtual;110-virtual;120-virtual;121-virtual"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_user_arch",
|
||||
"inherits": [ "mlx_cuda_v13_base" ],
|
||||
"binaryDir": "${sourceDir}/../../build/mlx_cuda_v13"
|
||||
},
|
||||
{
|
||||
"name": "mlx_metal_v3",
|
||||
"inherits": [ "default" ],
|
||||
"binaryDir": "${sourceDir}/../../build/metal-v3",
|
||||
"cacheVariables": {
|
||||
"CMAKE_OSX_DEPLOYMENT_TARGET": "14.0",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_metal_v3"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "mlx_metal_v4",
|
||||
"inherits": [ "default" ],
|
||||
"binaryDir": "${sourceDir}/../../build/metal-v4",
|
||||
"cacheVariables": {
|
||||
"CMAKE_OSX_DEPLOYMENT_TARGET": "26.0",
|
||||
"OLLAMA_RUNNER_DIR": "mlx_metal_v4"
|
||||
}
|
||||
}
|
||||
],
|
||||
"buildPresets": [
|
||||
{
|
||||
"name": "mlx_cuda_v13_linux",
|
||||
"configurePreset": "mlx_cuda_v13_linux",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_windows",
|
||||
"configurePreset": "mlx_cuda_v13_windows",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
},
|
||||
{
|
||||
"name": "mlx_cuda_v13_user_arch",
|
||||
"configurePreset": "mlx_cuda_v13_user_arch",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
},
|
||||
{
|
||||
"name": "mlx_metal_v3",
|
||||
"configurePreset": "mlx_metal_v3",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
},
|
||||
{
|
||||
"name": "mlx_metal_v4",
|
||||
"configurePreset": "mlx_metal_v4",
|
||||
"targets": [ "mlx", "mlxc" ]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
if(NOT DEFINED MLX_C_HEADERS_DIR OR NOT IS_DIRECTORY "${MLX_C_HEADERS_DIR}")
|
||||
message(FATAL_ERROR "MLX_C_HEADERS_DIR does not exist: ${MLX_C_HEADERS_DIR}")
|
||||
endif()
|
||||
if(NOT DEFINED MLX_C_HEADERS_DEST OR "${MLX_C_HEADERS_DEST}" STREQUAL "")
|
||||
message(FATAL_ERROR "MLX_C_HEADERS_DEST is required")
|
||||
endif()
|
||||
|
||||
file(GLOB _mlx_c_headers LIST_DIRECTORIES false "${MLX_C_HEADERS_DIR}/*.h")
|
||||
if(NOT _mlx_c_headers)
|
||||
message(FATAL_ERROR "No MLX-C headers found in ${MLX_C_HEADERS_DIR}")
|
||||
endif()
|
||||
|
||||
file(MAKE_DIRECTORY "${MLX_C_HEADERS_DEST}")
|
||||
file(COPY ${_mlx_c_headers} DESTINATION "${MLX_C_HEADERS_DEST}")
|
||||
@@ -0,0 +1,69 @@
|
||||
set(CMAKE_SYSTEM_NAME Windows)
|
||||
set(CMAKE_SYSTEM_PROCESSOR ARM64)
|
||||
|
||||
set(_ollama_llvm_mingw_hints)
|
||||
if(DEFINED ENV{ProgramFiles})
|
||||
file(GLOB _ollama_program_files_llvm_mingw_bins
|
||||
LIST_DIRECTORIES true
|
||||
"$ENV{ProgramFiles}/llvm-mingw-*-x86_64*/bin")
|
||||
list(SORT _ollama_program_files_llvm_mingw_bins COMPARE NATURAL ORDER DESCENDING)
|
||||
list(APPEND _ollama_llvm_mingw_hints ${_ollama_program_files_llvm_mingw_bins})
|
||||
endif()
|
||||
if(DEFINED ENV{LOCALAPPDATA})
|
||||
file(GLOB _ollama_winget_llvm_mingw_bins
|
||||
LIST_DIRECTORIES true
|
||||
"$ENV{LOCALAPPDATA}/Microsoft/WinGet/Packages/MartinStorsjo.LLVM-MinGW*/llvm-mingw-*-x86_64*/bin")
|
||||
list(SORT _ollama_winget_llvm_mingw_bins COMPARE NATURAL ORDER DESCENDING)
|
||||
list(APPEND _ollama_llvm_mingw_hints ${_ollama_winget_llvm_mingw_bins})
|
||||
endif()
|
||||
|
||||
if(NOT CMAKE_C_COMPILER)
|
||||
find_program(CMAKE_C_COMPILER
|
||||
NAMES aarch64-w64-mingw32-gcc
|
||||
HINTS ${_ollama_llvm_mingw_hints}
|
||||
REQUIRED)
|
||||
endif()
|
||||
|
||||
if(NOT CMAKE_CXX_COMPILER)
|
||||
find_program(CMAKE_CXX_COMPILER
|
||||
NAMES aarch64-w64-mingw32-g++
|
||||
HINTS ${_ollama_llvm_mingw_hints}
|
||||
REQUIRED)
|
||||
endif()
|
||||
|
||||
get_filename_component(_ollama_llvm_mingw_bin_dir "${CMAKE_CXX_COMPILER}" DIRECTORY)
|
||||
|
||||
if(NOT HOST_CXX_COMPILER)
|
||||
find_program(_ollama_path_host_cxx
|
||||
NAMES clang++ g++
|
||||
NO_CMAKE_FIND_ROOT_PATH)
|
||||
if(_ollama_path_host_cxx)
|
||||
set(HOST_CXX_COMPILER "${_ollama_path_host_cxx}")
|
||||
endif()
|
||||
endif()
|
||||
if(NOT HOST_CXX_COMPILER)
|
||||
find_program(_ollama_mingw_host_cxx
|
||||
NAMES x86_64-w64-mingw32-g++
|
||||
HINTS "${_ollama_llvm_mingw_bin_dir}"
|
||||
REQUIRED)
|
||||
if(CMAKE_HOST_WIN32)
|
||||
# llama.cpp builds a small host-only UI embedding tool during
|
||||
# cross-compiles, but currently models HOST_CXX_COMPILER as only an
|
||||
# executable path and has no companion host flags hook. When the host
|
||||
# compiler is llvm-mingw, the generated host tool otherwise depends on
|
||||
# llvm-mingw runtime DLLs being on PATH. Keep that workaround local and
|
||||
# explicit: wrap the compiler only to add -static for this host tool.
|
||||
set(_ollama_host_cxx_wrapper "${CMAKE_BINARY_DIR}/ollama-host-cxx.cmd")
|
||||
file(TO_NATIVE_PATH "${_ollama_mingw_host_cxx}" _ollama_mingw_host_cxx_native)
|
||||
file(WRITE "${_ollama_host_cxx_wrapper}"
|
||||
"@echo off\r\n"
|
||||
"\"${_ollama_mingw_host_cxx_native}\" -static %*\r\n")
|
||||
set(HOST_CXX_COMPILER "${_ollama_host_cxx_wrapper}")
|
||||
else()
|
||||
set(HOST_CXX_COMPILER "${_ollama_mingw_host_cxx}")
|
||||
endif()
|
||||
endif()
|
||||
set(HOST_CXX_COMPILER "${HOST_CXX_COMPILER}" CACHE FILEPATH "Host C++ compiler for build-time tools" FORCE)
|
||||
|
||||
string(PREPEND CMAKE_C_FLAGS_INIT "-D_WIN32_WINNT=0x0A00 ")
|
||||
string(PREPEND CMAKE_CXX_FLAGS_INIT "-D_WIN32_WINNT=0x0A00 ")
|
||||
+328
-57
@@ -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"
|
||||
@@ -54,34 +56,27 @@ import (
|
||||
"github.com/ollama/ollama/types/syncmap"
|
||||
"github.com/ollama/ollama/version"
|
||||
xcmd "github.com/ollama/ollama/x/cmd"
|
||||
xcreate "github.com/ollama/ollama/x/create"
|
||||
xcreateclient "github.com/ollama/ollama/x/create/client"
|
||||
"github.com/ollama/ollama/x/imagegen"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Override default selectors to use Bubbletea TUI instead of raw terminal I/O.
|
||||
launch.DefaultSingleSelector = func(title string, items []launch.ModelItem, current string) (string, error) {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return "", fmt.Errorf("model selection requires an interactive terminal; use --model to run in headless mode")
|
||||
}
|
||||
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
|
||||
result, err := tui.SelectSingle(title, tuiItems, current)
|
||||
if errors.Is(err, tui.ErrCancelled) {
|
||||
return "", launch.ErrCancelled
|
||||
}
|
||||
return result, err
|
||||
launch.DefaultSingleSelector = func(title string, items []launch.SelectionItem, current string) (string, error) {
|
||||
return runTUISingleSelector(title, items, current, nil)
|
||||
}
|
||||
|
||||
launch.DefaultMultiSelector = func(title string, items []launch.ModelItem, preChecked []string) ([]string, error) {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return nil, fmt.Errorf("model selection requires an interactive terminal; use --model to run in headless mode")
|
||||
}
|
||||
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
|
||||
result, err := tui.SelectMultiple(title, tuiItems, preChecked)
|
||||
if errors.Is(err, tui.ErrCancelled) {
|
||||
return nil, launch.ErrCancelled
|
||||
}
|
||||
return result, err
|
||||
launch.DefaultSingleSelectorWithUpdates = func(title string, items []launch.SelectionItem, current string, updates <-chan []launch.SelectionItem) (string, error) {
|
||||
return runTUISingleSelector(title, items, current, updates)
|
||||
}
|
||||
|
||||
launch.DefaultMultiSelector = func(title string, items []launch.SelectionItem, preChecked []string) ([]string, error) {
|
||||
return runTUIMultiSelector(title, items, preChecked, nil)
|
||||
}
|
||||
|
||||
launch.DefaultMultiSelectorWithUpdates = func(title string, items []launch.SelectionItem, preChecked []string, updates <-chan []launch.SelectionItem) ([]string, error) {
|
||||
return runTUIMultiSelector(title, items, preChecked, updates)
|
||||
}
|
||||
|
||||
launch.DefaultSignIn = func(modelName, signInURL string) (string, error) {
|
||||
@@ -92,9 +87,55 @@ func init() {
|
||||
return userName, err
|
||||
}
|
||||
|
||||
launch.DefaultUpgrade = func(modelName, requiredPlan string) (string, error) {
|
||||
plan, err := tui.RunUpgrade(modelName, requiredPlan)
|
||||
if errors.Is(err, tui.ErrCancelled) {
|
||||
return "", launch.ErrCancelled
|
||||
}
|
||||
return plan, err
|
||||
}
|
||||
|
||||
launch.DefaultConfirmPrompt = tui.RunConfirmWithOptions
|
||||
}
|
||||
|
||||
func runTUISingleSelector(title string, items []launch.SelectionItem, current string, updates <-chan []launch.SelectionItem) (string, error) {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return "", fmt.Errorf("model selection requires an interactive terminal; use --model to run in headless mode")
|
||||
}
|
||||
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
|
||||
result, err := tui.SelectSingleWithUpdates(title, tuiItems, current, convertSelectionItemUpdates(updates))
|
||||
if errors.Is(err, tui.ErrCancelled) {
|
||||
return "", launch.ErrCancelled
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func runTUIMultiSelector(title string, items []launch.SelectionItem, preChecked []string, updates <-chan []launch.SelectionItem) ([]string, error) {
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return nil, fmt.Errorf("model selection requires an interactive terminal; use --model to run in headless mode")
|
||||
}
|
||||
tuiItems := tui.ReorderItems(tui.ConvertItems(items))
|
||||
result, err := tui.SelectMultipleWithUpdates(title, tuiItems, preChecked, convertSelectionItemUpdates(updates))
|
||||
if errors.Is(err, tui.ErrCancelled) {
|
||||
return nil, launch.ErrCancelled
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func convertSelectionItemUpdates(updates <-chan []launch.SelectionItem) <-chan []tui.SelectItem {
|
||||
if updates == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(chan []tui.SelectItem, 1)
|
||||
go func() {
|
||||
defer close(out)
|
||||
for items := range updates {
|
||||
out <- tui.ReorderItems(tui.ConvertItems(items))
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
const ConnectInstructions = "If your browser did not open, navigate to:\n %s\n\n"
|
||||
|
||||
// ensureThinkingSupport emits a warning if the model does not advertise thinking support
|
||||
@@ -145,6 +186,39 @@ func isLocalhost() bool {
|
||||
return ip != nil && (ip.IsLoopback() || ip.IsUnspecified())
|
||||
}
|
||||
|
||||
func resolveExperimentalLocalModelDir(ref, filename string) string {
|
||||
if ref == "" || filepath.IsAbs(ref) || filename == "" {
|
||||
return ref
|
||||
}
|
||||
|
||||
candidate := filepath.Join(filepath.Dir(filename), ref)
|
||||
if xcreate.IsSafetensorsModelDir(candidate) || xcreate.IsTensorModelDir(candidate) {
|
||||
return candidate
|
||||
}
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
func resolveExperimentalDraftDir(ref, filename string) (string, error) {
|
||||
if ref == "" {
|
||||
return "", nil
|
||||
}
|
||||
if filepath.IsAbs(ref) {
|
||||
if xcreate.IsSafetensorsModelDir(ref) {
|
||||
return ref, nil
|
||||
}
|
||||
return "", fmt.Errorf("draft %s is not a supported safetensors model directory", ref)
|
||||
}
|
||||
if filename != "" {
|
||||
candidate := filepath.Join(filepath.Dir(filename), ref)
|
||||
if xcreate.IsSafetensorsModelDir(candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("DRAFT model references are not supported with --experimental yet: %s", ref)
|
||||
}
|
||||
|
||||
func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
p := progress.NewProgress(os.Stderr)
|
||||
defer p.Stop()
|
||||
@@ -159,6 +233,7 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
// Check for --experimental flag for safetensors model creation
|
||||
// This gates both safetensors LLM and imagegen model creation
|
||||
experimental, _ := cmd.Flags().GetBool("experimental")
|
||||
draftQuantize, _ := cmd.Flags().GetString("draft-quantize")
|
||||
if experimental {
|
||||
if !isLocalhost() {
|
||||
return errors.New("remote safetensor model creation not yet supported")
|
||||
@@ -192,17 +267,22 @@ func CreateHandler(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Resolve relative paths based on Modelfile location
|
||||
if !filepath.IsAbs(modelDir) && filename != "" {
|
||||
modelDir = filepath.Join(filepath.Dir(filename), modelDir)
|
||||
modelDir = resolveExperimentalLocalModelDir(modelDir, filename)
|
||||
if mfConfig.Draft != "" {
|
||||
draftDir, err := resolveExperimentalDraftDir(mfConfig.Draft, filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mfConfig.Draft = draftDir
|
||||
}
|
||||
|
||||
quantize, _ := cmd.Flags().GetString("quantize")
|
||||
return xcreateclient.CreateModel(xcreateclient.CreateOptions{
|
||||
ModelName: modelName,
|
||||
ModelDir: modelDir,
|
||||
Quantize: quantize,
|
||||
Modelfile: mfConfig,
|
||||
ModelName: modelName,
|
||||
ModelDir: modelDir,
|
||||
Quantize: quantize,
|
||||
DraftQuantize: draftQuantize,
|
||||
Modelfile: mfConfig,
|
||||
}, p)
|
||||
}
|
||||
|
||||
@@ -248,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 {
|
||||
@@ -258,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
|
||||
})
|
||||
}
|
||||
@@ -291,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 {
|
||||
@@ -328,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 {
|
||||
@@ -582,10 +739,10 @@ func RunHandler(cmd *cobra.Command, args []string) error {
|
||||
opts.Think = &api.ThinkValue{Value: true}
|
||||
case "false":
|
||||
opts.Think = &api.ThinkValue{Value: false}
|
||||
case "high", "medium", "low":
|
||||
case "high", "medium", "low", "max":
|
||||
opts.Think = &api.ThinkValue{Value: thinkStr}
|
||||
default:
|
||||
return fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, or low)", thinkStr)
|
||||
return fmt.Errorf("invalid value for --think: %q (must be true, false, high, medium, low, or max)", thinkStr)
|
||||
}
|
||||
} else {
|
||||
opts.Think = nil
|
||||
@@ -1196,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
|
||||
})
|
||||
}
|
||||
@@ -1928,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)
|
||||
@@ -1975,8 +2149,61 @@ func launchInteractiveModel(cmd *cobra.Command, modelName string) error {
|
||||
Options: map[string]any{},
|
||||
ShowConnect: true,
|
||||
}
|
||||
// loadOrUnloadModel is cloud-safe here: remote/cloud models skip local preload
|
||||
// and only validate auth/connectivity before interactive chat starts.
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestedCloud := modelref.HasExplicitCloudSource(modelName)
|
||||
|
||||
info, err := func() (*api.ShowResponse, error) {
|
||||
showReq := &api.ShowRequest{Name: modelName}
|
||||
info, err := client.Show(cmd.Context(), showReq)
|
||||
var se api.StatusError
|
||||
if errors.As(err, &se) && se.StatusCode == http.StatusNotFound {
|
||||
if requestedCloud {
|
||||
return nil, err
|
||||
}
|
||||
if err := PullHandler(cmd, []string{modelName}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return client.Show(cmd.Context(), &api.ShowRequest{Name: modelName})
|
||||
}
|
||||
return info, err
|
||||
}()
|
||||
if err != nil {
|
||||
if handleCloudAuthorizationError(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
ensureCloudStub(cmd.Context(), client, modelName)
|
||||
|
||||
opts.Think, err = inferThinkingOption(&info.Capabilities, &opts, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
audioCapable := slices.Contains(info.Capabilities, model.CapabilityAudio)
|
||||
opts.MultiModal = slices.Contains(info.Capabilities, model.CapabilityVision) || audioCapable
|
||||
|
||||
// TODO: remove the projector info and vision info checks below,
|
||||
// these are left in for backwards compatibility with older servers
|
||||
// that don't have the capabilities field in the model info
|
||||
if len(info.ProjectorInfo) != 0 {
|
||||
opts.MultiModal = true
|
||||
}
|
||||
for k := range info.ModelInfo {
|
||||
if strings.Contains(k, ".vision.") {
|
||||
opts.MultiModal = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
applyShowResponseToRunOptions(&opts, info)
|
||||
|
||||
if err := loadOrUnloadModel(cmd, &opts); err != nil {
|
||||
return fmt.Errorf("error loading model: %w", err)
|
||||
}
|
||||
@@ -1994,12 +2221,15 @@ func runInteractiveTUI(cmd *cobra.Command) {
|
||||
return
|
||||
}
|
||||
|
||||
accountPrefetch := launch.StartAccountStatePrefetch(cmd.Context())
|
||||
deps := launcherDeps{
|
||||
buildState: launch.BuildLauncherState,
|
||||
runMenu: tui.RunMenu,
|
||||
resolveRunModel: launch.ResolveRunModel,
|
||||
launchIntegration: launch.LaunchIntegration,
|
||||
runModel: launchInteractiveModel,
|
||||
buildState: launch.BuildLauncherState,
|
||||
runMenu: tui.RunMenu,
|
||||
resolveRunModel: launch.ResolveRunModel,
|
||||
launchIntegration: launch.LaunchIntegration,
|
||||
runModel: launchInteractiveModel,
|
||||
accountState: accountPrefetch.StateIfReady,
|
||||
accountStateUpdates: accountPrefetch.StateUpdates,
|
||||
}
|
||||
|
||||
for {
|
||||
@@ -2014,11 +2244,13 @@ func runInteractiveTUI(cmd *cobra.Command) {
|
||||
}
|
||||
|
||||
type launcherDeps struct {
|
||||
buildState func(context.Context) (*launch.LauncherState, error)
|
||||
runMenu func(*launch.LauncherState) (tui.TUIAction, error)
|
||||
resolveRunModel func(context.Context, launch.RunModelRequest) (string, error)
|
||||
launchIntegration func(context.Context, launch.IntegrationLaunchRequest) error
|
||||
runModel func(*cobra.Command, string) error
|
||||
buildState func(context.Context) (*launch.LauncherState, error)
|
||||
runMenu func(*launch.LauncherState) (tui.TUIAction, error)
|
||||
resolveRunModel func(context.Context, launch.RunModelRequest) (string, error)
|
||||
launchIntegration func(context.Context, launch.IntegrationLaunchRequest) error
|
||||
runModel func(*cobra.Command, string) error
|
||||
accountState func() *launch.AccountState
|
||||
accountStateUpdates func(context.Context) <-chan *launch.AccountState
|
||||
}
|
||||
|
||||
func runInteractiveTUIStep(cmd *cobra.Command, deps launcherDeps) (bool, error) {
|
||||
@@ -2026,6 +2258,9 @@ func runInteractiveTUIStep(cmd *cobra.Command, deps launcherDeps) (bool, error)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("build launcher state: %w", err)
|
||||
}
|
||||
if state != nil && deps.accountState != nil {
|
||||
state.AccountState = deps.accountState()
|
||||
}
|
||||
|
||||
action, err := deps.runMenu(state)
|
||||
if err != nil {
|
||||
@@ -2046,7 +2281,13 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
||||
return false, nil
|
||||
case tui.TUIActionRunModel:
|
||||
saveLauncherSelection(action)
|
||||
modelName, err := deps.resolveRunModel(cmd.Context(), action.RunModelRequest())
|
||||
req := action.RunModelRequest()
|
||||
if deps.accountState != nil {
|
||||
req.AccountState = deps.accountState()
|
||||
req.AccountStateProvider = deps.accountState
|
||||
}
|
||||
req.AccountStateUpdates = deps.accountStateUpdates
|
||||
modelName, err := deps.resolveRunModel(cmd.Context(), req)
|
||||
if errors.Is(err, launch.ErrCancelled) {
|
||||
return true, nil
|
||||
}
|
||||
@@ -2059,15 +2300,20 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
||||
return true, nil
|
||||
case tui.TUIActionLaunchIntegration:
|
||||
saveLauncherSelection(action)
|
||||
err := deps.launchIntegration(cmd.Context(), action.IntegrationLaunchRequest())
|
||||
req := action.IntegrationLaunchRequest()
|
||||
if deps.accountState != nil {
|
||||
req.AccountState = deps.accountState()
|
||||
req.AccountStateProvider = deps.accountState
|
||||
}
|
||||
req.AccountStateUpdates = deps.accountStateUpdates
|
||||
err := deps.launchIntegration(cmd.Context(), req)
|
||||
if errors.Is(err, launch.ErrCancelled) {
|
||||
return true, nil
|
||||
}
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("launching %s: %w", action.Integration, err)
|
||||
}
|
||||
// VS Code is a GUI app — exit the TUI loop after launching
|
||||
if action.Integration == "vscode" {
|
||||
if launcherActionExitsLoop(action.Integration) {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
@@ -2076,6 +2322,15 @@ func runLauncherAction(cmd *cobra.Command, action tui.TUIAction, deps launcherDe
|
||||
}
|
||||
}
|
||||
|
||||
func launcherActionExitsLoop(integration string) bool {
|
||||
switch integration {
|
||||
case "codex-app", "vscode":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NewCLI() *cobra.Command {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
cobra.EnableCommandSorting = false
|
||||
@@ -2122,6 +2377,7 @@ func NewCLI() *cobra.Command {
|
||||
|
||||
createCmd.Flags().StringP("file", "f", "", "Name of the Modelfile (default \"Modelfile\")")
|
||||
createCmd.Flags().StringP("quantize", "q", "", "Quantize model to this level (e.g. q4_K_M)")
|
||||
createCmd.Flags().String("draft-quantize", "", "Quantize draft model to this level")
|
||||
createCmd.Flags().Bool("experimental", false, "Enable experimental safetensors model creation")
|
||||
|
||||
showCmd := &cobra.Command{
|
||||
@@ -2279,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"]}
|
||||
@@ -2307,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"],
|
||||
@@ -2318,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:
|
||||
@@ -2342,6 +2612,7 @@ func NewCLI() *cobra.Command {
|
||||
copyCmd,
|
||||
deleteCmd,
|
||||
runnerCmd,
|
||||
gpuDiscoverCmd,
|
||||
launch.LaunchCmd(checkServerHeartbeat, runInteractiveTUI),
|
||||
)
|
||||
|
||||
|
||||
+52
-17
@@ -76,11 +76,18 @@ func TestRunInteractiveTUI_RunModelActionsUseResolveRunModel(t *testing.T) {
|
||||
|
||||
var gotReq launch.RunModelRequest
|
||||
var launched string
|
||||
prefetchedAccount := &launch.AccountState{}
|
||||
accountUpdates := func(context.Context) <-chan *launch.AccountState { return nil }
|
||||
deps := launcherDeps{
|
||||
buildState: func(ctx context.Context) (*launch.LauncherState, error) {
|
||||
return &launch.LauncherState{}, nil
|
||||
},
|
||||
runMenu: runMenu,
|
||||
runMenu: func(state *launch.LauncherState) (tui.TUIAction, error) {
|
||||
if state.AccountState != prefetchedAccount {
|
||||
t.Fatalf("prefetched account state was not piped to menu state")
|
||||
}
|
||||
return runMenu(state)
|
||||
},
|
||||
resolveRunModel: func(ctx context.Context, req launch.RunModelRequest) (string, error) {
|
||||
gotReq = req
|
||||
return tt.wantModel, nil
|
||||
@@ -90,6 +97,10 @@ func TestRunInteractiveTUI_RunModelActionsUseResolveRunModel(t *testing.T) {
|
||||
launched = model
|
||||
return nil
|
||||
},
|
||||
accountState: func() *launch.AccountState {
|
||||
return prefetchedAccount
|
||||
},
|
||||
accountStateUpdates: accountUpdates,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
@@ -107,6 +118,12 @@ func TestRunInteractiveTUI_RunModelActionsUseResolveRunModel(t *testing.T) {
|
||||
if gotReq.ForcePicker != tt.wantForce {
|
||||
t.Fatalf("expected ForcePicker=%v, got %v", tt.wantForce, gotReq.ForcePicker)
|
||||
}
|
||||
if gotReq.AccountState != prefetchedAccount {
|
||||
t.Fatalf("expected prefetched account state to be passed to run model request")
|
||||
}
|
||||
if gotReq.AccountStateUpdates == nil {
|
||||
t.Fatalf("expected account state updates to be passed to run model request")
|
||||
}
|
||||
if launched != tt.wantModel {
|
||||
t.Fatalf("expected interactive launcher to run %q, got %q", tt.wantModel, launched)
|
||||
}
|
||||
@@ -148,17 +165,28 @@ func TestRunInteractiveTUI_IntegrationActionsUseLaunchIntegration(t *testing.T)
|
||||
}
|
||||
|
||||
var gotReq launch.IntegrationLaunchRequest
|
||||
prefetchedAccount := &launch.AccountState{}
|
||||
accountUpdates := func(context.Context) <-chan *launch.AccountState { return nil }
|
||||
deps := launcherDeps{
|
||||
buildState: func(ctx context.Context) (*launch.LauncherState, error) {
|
||||
return &launch.LauncherState{}, nil
|
||||
},
|
||||
runMenu: runMenu,
|
||||
runMenu: func(state *launch.LauncherState) (tui.TUIAction, error) {
|
||||
if state.AccountState != prefetchedAccount {
|
||||
t.Fatalf("prefetched account state was not piped to menu state")
|
||||
}
|
||||
return runMenu(state)
|
||||
},
|
||||
resolveRunModel: unexpectedRunModelResolution(t),
|
||||
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||
gotReq = req
|
||||
return nil
|
||||
},
|
||||
runModel: unexpectedModelLaunch(t),
|
||||
accountState: func() *launch.AccountState {
|
||||
return prefetchedAccount
|
||||
},
|
||||
accountStateUpdates: accountUpdates,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
@@ -179,6 +207,12 @@ func TestRunInteractiveTUI_IntegrationActionsUseLaunchIntegration(t *testing.T)
|
||||
if gotReq.ForceConfigure != tt.wantForce {
|
||||
t.Fatalf("expected ForceConfigure=%v, got %v", tt.wantForce, gotReq.ForceConfigure)
|
||||
}
|
||||
if gotReq.AccountState != prefetchedAccount {
|
||||
t.Fatalf("expected prefetched account state to be passed to integration request")
|
||||
}
|
||||
if gotReq.AccountStateUpdates == nil {
|
||||
t.Fatalf("expected account state updates to be passed to integration request")
|
||||
}
|
||||
if got := config.LastSelection(); got != "claude" {
|
||||
t.Fatalf("expected last selection to be claude, got %q", got)
|
||||
}
|
||||
@@ -209,29 +243,30 @@ func TestRunLauncherAction_RunModelContinuesAfterCancellation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLauncherAction_VSCodeExitsTUILoop(t *testing.T) {
|
||||
func TestRunLauncherAction_GUIAppsExitTUILoop(t *testing.T) {
|
||||
setCmdTestHome(t, t.TempDir())
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetContext(context.Background())
|
||||
|
||||
// VS Code should exit the TUI loop (return false) after a successful launch.
|
||||
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "vscode"}, launcherDeps{
|
||||
resolveRunModel: unexpectedRunModelResolution(t),
|
||||
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||
return nil
|
||||
},
|
||||
runModel: unexpectedModelLaunch(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error, got %v", err)
|
||||
}
|
||||
if continueLoop {
|
||||
t.Fatal("expected vscode launch to exit the TUI loop (return false)")
|
||||
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 {
|
||||
return nil
|
||||
},
|
||||
runModel: unexpectedModelLaunch(t),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error for %s, got %v", integration, err)
|
||||
}
|
||||
if continueLoop {
|
||||
t.Fatalf("expected %s launch to exit the TUI loop (return false)", integration)
|
||||
}
|
||||
}
|
||||
|
||||
// Other integrations should continue the TUI loop (return true).
|
||||
continueLoop, err = runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
|
||||
continueLoop, err := runLauncherAction(cmd, tui.TUIAction{Kind: tui.TUIActionLaunchIntegration, Integration: "claude"}, launcherDeps{
|
||||
resolveRunModel: unexpectedRunModelResolution(t),
|
||||
launchIntegration: func(ctx context.Context, req launch.IntegrationLaunchRequest) error {
|
||||
return nil
|
||||
|
||||
+113
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -1524,6 +1525,118 @@ func TestCreateHandler(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
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 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\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.Flags().Bool("experimental", false, "")
|
||||
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-quantize requires a DRAFT model") {
|
||||
t.Fatalf("error = %v, want draft-quantize requires DRAFT", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExperimentalLocalModelDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
modelfile := filepath.Join(dir, "Modelfile")
|
||||
modelDir := filepath.Join(dir, "model")
|
||||
if err := os.Mkdir(modelDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(modelDir, "config.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(modelDir, "model.safetensors"), []byte("dummy"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := resolveExperimentalLocalModelDir("gemma4", modelfile); got != "gemma4" {
|
||||
t.Fatalf("resolveExperimentalLocalModelDir(model name) = %q, want gemma4", got)
|
||||
}
|
||||
if got := resolveExperimentalLocalModelDir("./model", modelfile); got != modelDir {
|
||||
t.Fatalf("resolveExperimentalLocalModelDir(local dir) = %q, want %q", got, modelDir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExperimentalDraftDir(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
modelfile := filepath.Join(dir, "Modelfile")
|
||||
draftDir := filepath.Join(dir, "assistant")
|
||||
if err := os.Mkdir(draftDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(draftDir, "config.json"), []byte(`{}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(draftDir, "model.safetensors"), []byte("dummy"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := resolveExperimentalDraftDir("./assistant", modelfile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != draftDir {
|
||||
t.Fatalf("resolveExperimentalDraftDir(local dir) = %q, want %q", got, draftDir)
|
||||
}
|
||||
|
||||
_, err = resolveExperimentalDraftDir("assistant-model", modelfile)
|
||||
if err == nil || !strings.Contains(err.Error(), "DRAFT model references are not supported with --experimental yet") {
|
||||
t.Fatalf("error = %v, want unsupported draft model reference", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewCreateRequest(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -8,9 +8,16 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Keep a bounded number of backups per file so config backups do not grow
|
||||
// without limit. We keep the 5 most recent backups and do not pin the oldest.
|
||||
const maxBackupsPerFile = 5
|
||||
|
||||
// ReadJSON reads a JSON object file into a generic map.
|
||||
func ReadJSON(path string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
@@ -36,34 +43,51 @@ func copyFile(src, dst string) error {
|
||||
return os.WriteFile(dst, data, info.Mode().Perm())
|
||||
}
|
||||
|
||||
// BackupDir returns the shared backup directory used before overwriting files.
|
||||
// BackupDir returns the shared backup root used before overwriting files.
|
||||
func BackupDir() string {
|
||||
return filepath.Join(os.TempDir(), "ollama-backups")
|
||||
if home, err := os.UserHomeDir(); err == nil && home != "" {
|
||||
return filepath.Join(home, ".ollama", "backup")
|
||||
}
|
||||
return filepath.Join(os.TempDir(), "ollama-backup")
|
||||
}
|
||||
|
||||
func backupToTmp(srcPath string) (string, error) {
|
||||
func writeBackupCopy(srcPath string, integration string) (string, error) {
|
||||
dir := BackupDir()
|
||||
name := filepath.Base(srcPath)
|
||||
if integration != "" {
|
||||
dir = filepath.Join(dir, integration)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(dir, fmt.Sprintf("%s.%d", filepath.Base(srcPath), time.Now().Unix()))
|
||||
backupPath := filepath.Join(dir, fmt.Sprintf("%s.%d", name, time.Now().Unix()))
|
||||
if err := copyFile(srcPath, backupPath); err != nil {
|
||||
return "", err
|
||||
}
|
||||
pruneOldBackups(dir, name, maxBackupsPerFile)
|
||||
return backupPath, nil
|
||||
}
|
||||
|
||||
// WriteWithBackup writes data to path via temp file + rename, backing up any existing file first.
|
||||
func WriteWithBackup(path string, data []byte) error {
|
||||
// WriteWithBackup writes data to path via temp file + rename, backing up any
|
||||
// existing file first. Callers may optionally pass one integration name to
|
||||
// store backups under BackupDir()/.../<integration>/.
|
||||
func WriteWithBackup(path string, data []byte, integration ...string) error {
|
||||
backupIntegration := ""
|
||||
if len(integration) > 0 {
|
||||
backupIntegration = integration[0]
|
||||
}
|
||||
|
||||
var backupPath string
|
||||
// backup must be created before any writes to the target file
|
||||
if existingContent, err := os.ReadFile(path); err == nil {
|
||||
if !bytes.Equal(existingContent, data) {
|
||||
backupPath, err = backupToTmp(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
if bytes.Equal(existingContent, data) {
|
||||
return nil
|
||||
}
|
||||
backupPath, err = writeBackupCopy(path, backupIntegration)
|
||||
if err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read existing file: %w", err)
|
||||
@@ -101,3 +125,52 @@ func WriteWithBackup(path string, data []byte) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func pruneOldBackups(dir, name string, keep int) {
|
||||
if keep < 1 {
|
||||
return
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
type backupEntry struct {
|
||||
name string
|
||||
timestamp int64
|
||||
}
|
||||
|
||||
prefix := name + "."
|
||||
backups := make([]backupEntry, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasPrefix(entry.Name(), prefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
timestamp, err := strconv.ParseInt(strings.TrimPrefix(entry.Name(), prefix), 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
backups = append(backups, backupEntry{
|
||||
name: entry.Name(),
|
||||
timestamp: timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
if len(backups) <= keep {
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(backups, func(i, j int) bool {
|
||||
if backups[i].timestamp != backups[j].timestamp {
|
||||
return backups[i].timestamp > backups[j].timestamp
|
||||
}
|
||||
return backups[i].name > backups[j].name
|
||||
})
|
||||
|
||||
for _, backup := range backups[keep:] {
|
||||
_ = os.Remove(filepath.Join(dir, backup.name))
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,12 @@ func TestMain(m *testing.M) {
|
||||
if err := os.Setenv("TMPDIR", tmpRoot); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("HOME", tmpRoot); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("USERPROFILE", tmpRoot); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(tmpRoot)
|
||||
@@ -41,6 +47,17 @@ func isolatedTempDir(t *testing.T) string {
|
||||
func TestWriteWithBackup(t *testing.T) {
|
||||
tmpDir := isolatedTempDir(t)
|
||||
|
||||
t.Run("uses ollama directory under home", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
want := filepath.Join(home, ".ollama", "backup")
|
||||
if got := BackupDir(); got != want {
|
||||
t.Fatalf("BackupDir() = %q, want %q", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("creates file", func(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "new.json")
|
||||
data := mustMarshal(t, map[string]string{"key": "value"})
|
||||
@@ -63,7 +80,7 @@ func TestWriteWithBackup(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("creates backup in the temp backup directory", func(t *testing.T) {
|
||||
t.Run("creates backup in the shared backup directory", func(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "backup.json")
|
||||
|
||||
os.WriteFile(path, []byte(`{"original": true}`), 0o644)
|
||||
@@ -110,6 +127,35 @@ func TestWriteWithBackup(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stores hinted backups under a subdirectory", func(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "hinted.json")
|
||||
os.WriteFile(path, []byte(`{"original": true}`), 0o644)
|
||||
|
||||
data := mustMarshal(t, map[string]bool{"updated": true})
|
||||
if err := WriteWithBackup(path, data, "openclaw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(filepath.Join(BackupDir(), "openclaw"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var found bool
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if len(name) > len("hinted.json.") && name[:len("hinted.json.")] == "hinted.json." {
|
||||
found = true
|
||||
_ = os.Remove(filepath.Join(BackupDir(), "openclaw", name))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
t.Error("backup file was not created under hint directory")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no backup for new file", func(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "nobak.json")
|
||||
|
||||
@@ -189,6 +235,35 @@ func TestWriteWithBackup(t *testing.T) {
|
||||
t.Error("backup file with timestamp not found")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("retains only the five newest backups per file", func(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "pruned.json")
|
||||
if err := os.WriteFile(path, []byte(`{"v": 0}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 1; i <= maxBackupsPerFile; i++ {
|
||||
backupPath := filepath.Join(BackupDir(), fmt.Sprintf("pruned.json.%d", i))
|
||||
if err := os.WriteFile(backupPath, []byte(fmt.Sprintf(`{"v": %d}`, i)), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := WriteWithBackup(path, []byte(`{"v": 1}`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
backups, err := filepath.Glob(filepath.Join(BackupDir(), "pruned.json.*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backups) != maxBackupsPerFile {
|
||||
t.Fatalf("expected %d backups after pruning, got %d", maxBackupsPerFile, len(backups))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(BackupDir(), "pruned.json.1")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected oldest backup to be pruned, stat err = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Edge case tests for files.go
|
||||
@@ -251,6 +326,36 @@ func TestWriteWithBackup_PermissionDenied(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteWithBackup_UnchangedContentIsNoOp(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("permission tests unreliable on Windows")
|
||||
}
|
||||
|
||||
tmpDir := isolatedTempDir(t)
|
||||
path := filepath.Join(tmpDir, "unchanged-noop.json")
|
||||
data := []byte(`{"same":true}`)
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpDir, 0o555); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.Chmod(tmpDir, 0o755)
|
||||
|
||||
if err := WriteWithBackup(path, data); err != nil {
|
||||
t.Fatalf("expected unchanged write to be a no-op, got %v", err)
|
||||
}
|
||||
|
||||
backups, err := filepath.Glob(filepath.Join(BackupDir(), "unchanged-noop.json.*"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(backups) != 0 {
|
||||
t.Fatalf("expected no backups for unchanged content, got %d", len(backups))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteWithBackup_DirectoryDoesNotExist verifies behavior when target directory doesn't exist.
|
||||
// writeWithBackup doesn't create directories - caller is responsible.
|
||||
func TestWriteWithBackup_DirectoryDoesNotExist(t *testing.T) {
|
||||
@@ -302,9 +407,9 @@ func TestBackupToTmp_SpecialCharsInFilename(t *testing.T) {
|
||||
path := filepath.Join(tmpDir, "my config (backup).json")
|
||||
os.WriteFile(path, []byte(`{"test": true}`), 0o644)
|
||||
|
||||
backupPath, err := backupToTmp(path)
|
||||
backupPath, err := writeBackupCopy(path, "")
|
||||
if err != nil {
|
||||
t.Fatalf("backupToTmp with special chars failed: %v", err)
|
||||
t.Fatalf("writeBackupCopy with special chars failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify backup exists and has correct content
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultUpgradeURL is the fixed destination for subscription upgrades.
|
||||
DefaultUpgradeURL = "https://ollama.com/upgrade"
|
||||
|
||||
accountCheckTimeout = 3 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPlanVerificationUnavailable = errors.New("Could not verify your plan. Try again in a moment.")
|
||||
errUpgradeCancelled = errors.New("upgrade cancelled")
|
||||
)
|
||||
|
||||
type accountStateStatus int
|
||||
|
||||
const (
|
||||
accountStateUnknown accountStateStatus = iota
|
||||
accountStateSignedOut
|
||||
accountStateSignedIn
|
||||
)
|
||||
|
||||
type AccountState struct {
|
||||
Status accountStateStatus
|
||||
Plan string
|
||||
}
|
||||
|
||||
type AccountStatePrefetch struct {
|
||||
done chan struct{}
|
||||
state AccountState
|
||||
}
|
||||
|
||||
func StartAccountStatePrefetch(ctx context.Context) *AccountStatePrefetch {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
p := &AccountStatePrefetch{done: make(chan struct{})}
|
||||
go func() {
|
||||
state := AccountState{Status: accountStateUnknown}
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err == nil {
|
||||
prefetchCtx, cancel := context.WithTimeout(ctx, accountCheckTimeout)
|
||||
defer cancel()
|
||||
if disabled, known := cloudStatusDisabled(prefetchCtx, client); !known || !disabled {
|
||||
state = launchAccountState(prefetchCtx, client)
|
||||
}
|
||||
}
|
||||
p.state = state
|
||||
close(p.done)
|
||||
}()
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *AccountStatePrefetch) StateIfReady() *AccountState {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-p.done:
|
||||
state := p.state
|
||||
return &state
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *AccountStatePrefetch) StateUpdates(ctx context.Context) <-chan *AccountState {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
out := make(chan *AccountState, 1)
|
||||
go func() {
|
||||
defer close(out)
|
||||
select {
|
||||
case <-p.done:
|
||||
if p.state.Status == accountStateUnknown {
|
||||
return
|
||||
}
|
||||
state := p.state
|
||||
select {
|
||||
case out <- &state:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func launchAccountState(ctx context.Context, client *api.Client) AccountState {
|
||||
if client == nil {
|
||||
return AccountState{Status: accountStateUnknown}
|
||||
}
|
||||
|
||||
user, err := whoamiWithTimeout(ctx, client)
|
||||
if err != nil {
|
||||
var authErr api.AuthorizationError
|
||||
if errors.As(err, &authErr) && authErr.StatusCode == http.StatusUnauthorized {
|
||||
return AccountState{Status: accountStateSignedOut}
|
||||
}
|
||||
return AccountState{Status: accountStateUnknown}
|
||||
}
|
||||
if user == nil || strings.TrimSpace(user.Name) == "" {
|
||||
return AccountState{Status: accountStateSignedOut}
|
||||
}
|
||||
return AccountState{
|
||||
Status: accountStateSignedIn,
|
||||
Plan: strings.TrimSpace(user.Plan),
|
||||
}
|
||||
}
|
||||
|
||||
func whoamiWithTimeout(ctx context.Context, client *api.Client) (*api.UserResponse, error) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
checkCtx, cancel := context.WithTimeout(ctx, accountCheckTimeout)
|
||||
defer cancel()
|
||||
return client.Whoami(checkCtx)
|
||||
}
|
||||
|
||||
func ApplyAccountStateToSelectionItems(items []ModelItem, state AccountState) []SelectionItem {
|
||||
out := make([]SelectionItem, len(items))
|
||||
for i, item := range items {
|
||||
out[i] = SelectionItem{
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
Recommended: item.Recommended,
|
||||
AvailabilityBadge: availabilityBadge(item, state),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func SelectionItemsWithAccountState(items []ModelItem, state *AccountState) []SelectionItem {
|
||||
if state == nil || !selectionItemsNeedAccountState(items) {
|
||||
return ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateUnknown})
|
||||
}
|
||||
return ApplyAccountStateToSelectionItems(items, *state)
|
||||
}
|
||||
|
||||
func selectionItemsNeedAccountState(items []ModelItem) bool {
|
||||
for _, item := range items {
|
||||
if isCloudModelName(item.Name) && itemHasRecommendationMetadata(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *launcherClient) selectionItemUpdates(ctx context.Context, items []ModelItem, state *AccountState) <-chan []SelectionItem {
|
||||
if !selectionItemsNeedAccountState(items) || state != nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
stateUpdates := c.accountStateUpdateSource(ctx)
|
||||
if stateUpdates == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := make(chan []SelectionItem, 1)
|
||||
go func() {
|
||||
defer close(out)
|
||||
select {
|
||||
case state, ok := <-stateUpdates:
|
||||
if !ok || state == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case out <- SelectionItemsWithAccountState(items, state):
|
||||
case <-ctx.Done():
|
||||
}
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *launcherClient) accountStateUpdateSource(ctx context.Context) <-chan *AccountState {
|
||||
if c.accountStateUpdates != nil {
|
||||
return c.accountStateUpdates(ctx)
|
||||
}
|
||||
if c.apiClient == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(chan *AccountState, 1)
|
||||
go func() {
|
||||
defer close(out)
|
||||
state := launchAccountState(ctx, c.apiClient)
|
||||
if state.Status == accountStateUnknown {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case out <- &state:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
return out
|
||||
}
|
||||
|
||||
func availabilityBadge(item ModelItem, state AccountState) string {
|
||||
if !isCloudModelName(item.Name) {
|
||||
return ""
|
||||
}
|
||||
switch state.Status {
|
||||
case accountStateSignedOut:
|
||||
if itemHasRecommendationMetadata(item) {
|
||||
return "Sign in required"
|
||||
}
|
||||
case accountStateSignedIn:
|
||||
if item.RequiredPlan != "" && !PlanSatisfies(state.Plan, item.RequiredPlan) {
|
||||
return "Upgrade required"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func itemHasRecommendationMetadata(item ModelItem) bool {
|
||||
return item.Recommended || strings.TrimSpace(item.RequiredPlan) != ""
|
||||
}
|
||||
|
||||
func (c *launcherClient) ensureCloudModelAccess(ctx context.Context, model string) error {
|
||||
item, ok := c.modelRecommendationItem(ctx, model)
|
||||
if !ok || strings.TrimSpace(item.RequiredPlan) == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
state := launchAccountState(ctx, c.apiClient)
|
||||
if state.Status != accountStateUnknown {
|
||||
c.accountState = &state
|
||||
}
|
||||
if state.Status == accountStateUnknown {
|
||||
return ErrPlanVerificationUnavailable
|
||||
}
|
||||
|
||||
if state.Status == accountStateSignedOut {
|
||||
if err := ensureCloudAuth(ctx, c.apiClient, model); err != nil {
|
||||
return err
|
||||
}
|
||||
state = launchAccountState(ctx, c.apiClient)
|
||||
if state.Status != accountStateUnknown {
|
||||
c.accountState = &state
|
||||
}
|
||||
if state.Status == accountStateUnknown {
|
||||
return ErrPlanVerificationUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
if PlanSatisfies(state.Plan, item.RequiredPlan) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := c.runUpgradeFlow(ctx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
state = launchAccountState(ctx, c.apiClient)
|
||||
if state.Status == accountStateUnknown {
|
||||
return ErrPlanVerificationUnavailable
|
||||
}
|
||||
if state.Status != accountStateSignedIn || !PlanSatisfies(state.Plan, item.RequiredPlan) {
|
||||
return errUpgradeCancelled
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *launcherClient) modelRecommendationItem(ctx context.Context, model string) (ModelItem, bool) {
|
||||
for _, item := range c.recommendations(ctx) {
|
||||
if item.Name == model {
|
||||
return item, true
|
||||
}
|
||||
}
|
||||
return ModelItem{}, false
|
||||
}
|
||||
|
||||
func (c *launcherClient) runUpgradeFlow(ctx context.Context, item ModelItem) error {
|
||||
if DefaultUpgrade != nil {
|
||||
if _, err := DefaultUpgrade(item.Name, item.RequiredPlan); err != nil {
|
||||
if errors.Is(err, ErrCancelled) {
|
||||
return errUpgradeCancelled
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
yes, err := ConfirmPrompt(fmt.Sprintf("Upgrade to use %s?", item.Name))
|
||||
if errors.Is(err, ErrCancelled) {
|
||||
return errUpgradeCancelled
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !yes {
|
||||
return errUpgradeCancelled
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nTo upgrade, navigate to:\n %s\n\n", DefaultUpgradeURL)
|
||||
openNow, err := ConfirmPrompt("Open now?")
|
||||
if errors.Is(err, ErrCancelled) {
|
||||
return errUpgradeCancelled
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if openNow {
|
||||
OpenBrowser(DefaultUpgradeURL)
|
||||
} else {
|
||||
return errUpgradeCancelled
|
||||
}
|
||||
|
||||
spinnerFrames := []string{"|", "/", "-", "\\"}
|
||||
frame := 0
|
||||
fmt.Fprintf(os.Stderr, "\033[90mwaiting for upgrade to complete... %s\033[0m", spinnerFrames[0])
|
||||
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
fmt.Fprintf(os.Stderr, "\r\033[K")
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
frame++
|
||||
fmt.Fprintf(os.Stderr, "\r\033[90mwaiting for upgrade to complete... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)])
|
||||
if frame%10 != 0 {
|
||||
continue
|
||||
}
|
||||
state := launchAccountState(ctx, c.apiClient)
|
||||
if state.Status == accountStateUnknown {
|
||||
fmt.Fprintf(os.Stderr, "\r\033[K")
|
||||
return ErrPlanVerificationUnavailable
|
||||
}
|
||||
if state.Status == accountStateSignedIn && PlanSatisfies(state.Plan, item.RequiredPlan) {
|
||||
fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1mplan updated\033[0m\n")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// PlanSatisfies reports whether currentPlan can use a model that has a requiredPlan.
|
||||
func PlanSatisfies(currentPlan, requiredPlan string) bool {
|
||||
required := normalizePlan(requiredPlan)
|
||||
if required == "" || required == "free" {
|
||||
return true
|
||||
}
|
||||
current := normalizePlan(currentPlan)
|
||||
return current != "" && current != "free"
|
||||
}
|
||||
|
||||
func normalizePlan(plan string) string {
|
||||
return strings.ToLower(strings.TrimSpace(plan))
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -0,0 +1,888 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
claudeDesktopIntegrationName = "claude-desktop"
|
||||
claudeDesktopProfileName = "Ollama"
|
||||
claudeDesktopProfileID = "00000000-0000-4000-8000-000000000114"
|
||||
claudeDesktopGatewayBaseURL = "https://ollama.com"
|
||||
claudeDesktopAPIKeyURL = "https://ollama.com/settings/keys"
|
||||
claudeDesktopModelLabel = "Ollama Cloud"
|
||||
claudeDesktopUnsupported = "Claude Desktop is no longer supported. Existing installations can be restored with 'ollama launch claude-desktop --restore'."
|
||||
claudeDesktopSuccessMessage = "Claude Desktop profile changed to Ollama Cloud."
|
||||
claudeDesktopRestoreMessage = "To restore the usual Claude profile, run: ollama launch claude-desktop --restore"
|
||||
claudeDesktopRestoredMessage = "Claude Desktop restored to the usual Claude profile."
|
||||
)
|
||||
|
||||
var (
|
||||
claudeDesktopGOOS = runtime.GOOS
|
||||
claudeDesktopUserHome = os.UserHomeDir
|
||||
claudeDesktopStat = os.Stat
|
||||
claudeDesktopOpenApp = defaultClaudeDesktopOpenApp
|
||||
claudeDesktopOpenAppPath = defaultClaudeDesktopOpenAppPath
|
||||
claudeDesktopQuitApp = defaultClaudeDesktopQuitApp
|
||||
claudeDesktopIsRunning = defaultClaudeDesktopIsRunning
|
||||
claudeDesktopRunningAppPath = defaultClaudeDesktopRunningAppPath
|
||||
claudeDesktopGlob = filepath.Glob
|
||||
claudeDesktopSleep = time.Sleep
|
||||
claudeDesktopHTTPClient = http.DefaultClient
|
||||
claudeDesktopPromptAPIKey = promptClaudeDesktopAPIKey
|
||||
claudeDesktopValidateAPIKey = validateClaudeDesktopAPIKey
|
||||
)
|
||||
|
||||
// ClaudeDesktop configures and launches Claude Desktop in third-party
|
||||
// inference mode using Ollama Cloud as the gateway.
|
||||
type ClaudeDesktop struct{}
|
||||
|
||||
func (c *ClaudeDesktop) String() string { return "Claude Desktop" }
|
||||
|
||||
func (c *ClaudeDesktop) Supported() error { return claudeDesktopSupported() }
|
||||
|
||||
func (c *ClaudeDesktop) Paths() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) AutodiscoveredModel() string {
|
||||
return claudeDesktopModelLabel
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) ConfigureAutodiscovery() error {
|
||||
if err := claudeDesktopSupported(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targets, err := claudeDesktopTargetPaths()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, err := claudeDesktopValidatedAPIKey(context.Background(), claudeDesktopTargetProfilePaths(targets))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, path := range targets.normalConfigs {
|
||||
if err := writeClaudeDesktopDeploymentMode(path, "3p"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, target := range targets.thirdPartyProfiles {
|
||||
if err := writeClaudeDesktopDeploymentMode(target.desktopConfig, "3p"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeClaudeDesktopMeta(target.meta, claudeDesktopProfileID, claudeDesktopProfileName); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeClaudeDesktopGatewayProfile(target.profile, key, true); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) RestoreHint() string {
|
||||
return claudeDesktopRestoreMessage
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) ConfigurationSuccessMessage() string {
|
||||
return claudeDesktopSuccessMessage + "\n" + claudeDesktopRestoreMessage
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) RestoreSuccessMessage() string {
|
||||
return claudeDesktopRestoredMessage
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) AutodiscoveryConfigured() bool {
|
||||
targets, err := claudeDesktopTargetPaths()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return claudeDesktopTargetsConfigured(targets)
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) Onboard() error {
|
||||
return config.MarkIntegrationOnboarded(claudeDesktopIntegrationName)
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) RequiresInteractiveOnboarding() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) SkipModelReadiness() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) Run(_ string, _ []LaunchModel, _ []string) error {
|
||||
return errClaudeDesktopUnsupported()
|
||||
}
|
||||
|
||||
func (c *ClaudeDesktop) Restore() error {
|
||||
if err := claudeDesktopSupported(); err != nil {
|
||||
return err
|
||||
}
|
||||
targets, err := claudeDesktopTargetPaths()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, path := range targets.normalConfigs {
|
||||
if err := writeClaudeDesktopDeploymentMode(path, "1p"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, target := range targets.thirdPartyProfiles {
|
||||
if err := writeClaudeDesktopDeploymentMode(target.desktopConfig, "1p"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := restoreClaudeDesktopMeta(target.meta); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := restoreClaudeDesktopOllamaProfile(target.profile); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return claudeDesktopLaunchOrRestart("Restart Claude Desktop to use the usual Claude profile?")
|
||||
}
|
||||
|
||||
func errClaudeDesktopUnsupported() error {
|
||||
return errors.New(claudeDesktopUnsupported)
|
||||
}
|
||||
|
||||
func claudeDesktopSupported() error {
|
||||
switch claudeDesktopGOOS {
|
||||
case "darwin", "windows":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("Claude Desktop launch is only supported on macOS and Windows")
|
||||
}
|
||||
}
|
||||
|
||||
func claudeDesktopInstalled() bool {
|
||||
if claudeDesktopAppPath() != "" {
|
||||
return true
|
||||
}
|
||||
if claudeDesktopGOOS == "windows" && claudeDesktopIsRunning() {
|
||||
return true
|
||||
}
|
||||
for _, dir := range claudeDesktopProfileDirCandidates(false) {
|
||||
if _, err := claudeDesktopStat(dir); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func claudeDesktopAppPath() string {
|
||||
if claudeDesktopGOOS != "darwin" && claudeDesktopGOOS != "windows" {
|
||||
return ""
|
||||
}
|
||||
for _, path := range claudeDesktopAppCandidates() {
|
||||
if _, err := claudeDesktopStat(path); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func claudeDesktopAppCandidates() []string {
|
||||
switch claudeDesktopGOOS {
|
||||
case "darwin":
|
||||
return claudeDesktopDarwinAppCandidates()
|
||||
case "windows":
|
||||
return claudeDesktopWindowsAppCandidates()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func claudeDesktopDarwinAppCandidates() []string {
|
||||
candidates := []string{"/Applications/Claude.app"}
|
||||
if home, err := claudeDesktopUserHome(); err == nil {
|
||||
candidates = append(candidates, filepath.Join(home, "Applications", "Claude.app"))
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func claudeDesktopWindowsAppCandidates() []string {
|
||||
local, err := claudeDesktopLocalAppData()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
candidates := []string{
|
||||
filepath.Join(local, "Programs", "Claude", "Claude.exe"),
|
||||
filepath.Join(local, "Programs", "Claude Desktop", "Claude.exe"),
|
||||
filepath.Join(local, "Claude", "Claude.exe"),
|
||||
filepath.Join(local, "Claude Nest", "Claude.exe"),
|
||||
filepath.Join(local, "Claude Desktop", "Claude.exe"),
|
||||
filepath.Join(local, "AnthropicClaude", "Claude.exe"),
|
||||
}
|
||||
for _, pattern := range []string{
|
||||
filepath.Join(local, "AnthropicClaude", "app-*", "Claude.exe"),
|
||||
filepath.Join(local, "Programs", "Claude", "app-*", "Claude.exe"),
|
||||
filepath.Join(local, "Programs", "Claude Desktop", "app-*", "Claude.exe"),
|
||||
} {
|
||||
matches, _ := claudeDesktopGlob(pattern)
|
||||
candidates = append(candidates, matches...)
|
||||
}
|
||||
return claudeDesktopDedupePaths(candidates)
|
||||
}
|
||||
|
||||
func claudeDesktopDedupePaths(paths []string) []string {
|
||||
out := make([]string, 0, len(paths))
|
||||
seen := make(map[string]bool, len(paths))
|
||||
for _, path := range paths {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(path)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
out = append(out, path)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type claudeDesktopPaths struct {
|
||||
normalConfig string
|
||||
desktopConfig string
|
||||
meta string
|
||||
profile string
|
||||
}
|
||||
|
||||
type claudeDesktopThirdPartyPaths struct {
|
||||
desktopConfig string
|
||||
meta string
|
||||
profile string
|
||||
}
|
||||
|
||||
type claudeDesktopTargets struct {
|
||||
normalConfigs []string
|
||||
thirdPartyProfiles []claudeDesktopThirdPartyPaths
|
||||
}
|
||||
|
||||
func claudeDesktopConfigPaths() (claudeDesktopPaths, error) {
|
||||
switch claudeDesktopGOOS {
|
||||
case "darwin":
|
||||
return claudeDesktopDarwinConfigPaths()
|
||||
case "windows":
|
||||
return claudeDesktopWindowsConfigPaths()
|
||||
default:
|
||||
return claudeDesktopPaths{}, claudeDesktopSupported()
|
||||
}
|
||||
}
|
||||
|
||||
func claudeDesktopDarwinConfigPaths() (claudeDesktopPaths, error) {
|
||||
normalRoots, thirdPartyRoots, err := claudeDesktopDarwinProfileRoots()
|
||||
if err != nil {
|
||||
return claudeDesktopPaths{}, err
|
||||
}
|
||||
normalBase := normalRoots[0]
|
||||
thirdPartyBase := thirdPartyRoots[0]
|
||||
return claudeDesktopPaths{
|
||||
normalConfig: filepath.Join(normalBase, "claude_desktop_config.json"),
|
||||
desktopConfig: filepath.Join(thirdPartyBase, "claude_desktop_config.json"),
|
||||
meta: filepath.Join(thirdPartyBase, "configLibrary", "_meta.json"),
|
||||
profile: filepath.Join(thirdPartyBase, "configLibrary", claudeDesktopProfileID+".json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func claudeDesktopWindowsConfigPaths() (claudeDesktopPaths, error) {
|
||||
normalBase, err := claudeDesktopProfileDir(true)
|
||||
if err != nil {
|
||||
return claudeDesktopPaths{}, err
|
||||
}
|
||||
thirdPartyBase, err := claudeDesktopProfileDir(false)
|
||||
if err != nil {
|
||||
return claudeDesktopPaths{}, err
|
||||
}
|
||||
return claudeDesktopPaths{
|
||||
normalConfig: filepath.Join(normalBase, "claude_desktop_config.json"),
|
||||
desktopConfig: filepath.Join(thirdPartyBase, "claude_desktop_config.json"),
|
||||
meta: filepath.Join(thirdPartyBase, "configLibrary", "_meta.json"),
|
||||
profile: filepath.Join(thirdPartyBase, "configLibrary", claudeDesktopProfileID+".json"),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func claudeDesktopProfileDir(normal bool) (string, error) {
|
||||
candidates := claudeDesktopProfileDirCandidates(normal)
|
||||
if len(candidates) == 0 {
|
||||
return "", fmt.Errorf("Claude Desktop profile directory could not be resolved")
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := claudeDesktopStat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return candidates[0], nil
|
||||
}
|
||||
|
||||
func claudeDesktopProfileDirCandidates(normal bool) []string {
|
||||
if claudeDesktopGOOS != "windows" {
|
||||
return nil
|
||||
}
|
||||
normalRoots, thirdPartyRoots, err := claudeDesktopWindowsProfileRoots()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if normal {
|
||||
return normalRoots
|
||||
}
|
||||
return thirdPartyRoots
|
||||
}
|
||||
|
||||
func claudeDesktopDarwinProfileRoots() ([]string, []string, error) {
|
||||
home, err := claudeDesktopUserHome()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
base := filepath.Join(home, "Library", "Application Support")
|
||||
return []string{filepath.Join(base, "Claude")}, []string{filepath.Join(base, "Claude-3p")}, nil
|
||||
}
|
||||
|
||||
func claudeDesktopWindowsProfileRoots() ([]string, []string, error) {
|
||||
local, err := claudeDesktopLocalAppData()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
normalRoots := []string{
|
||||
filepath.Join(local, "Claude"),
|
||||
filepath.Join(local, "Claude Nest"),
|
||||
}
|
||||
thirdPartyRoots := []string{
|
||||
filepath.Join(local, "Claude-3p"),
|
||||
filepath.Join(local, "Claude Nest-3p"),
|
||||
}
|
||||
return normalRoots, thirdPartyRoots, nil
|
||||
}
|
||||
|
||||
func claudeDesktopTargetPaths() (claudeDesktopTargets, error) {
|
||||
var (
|
||||
normalRoots []string
|
||||
thirdPartyRoots []string
|
||||
err error
|
||||
)
|
||||
|
||||
switch claudeDesktopGOOS {
|
||||
case "darwin":
|
||||
normalRoots, thirdPartyRoots, err = claudeDesktopDarwinProfileRoots()
|
||||
case "windows":
|
||||
normalRoots, thirdPartyRoots, err = claudeDesktopWindowsProfileRoots()
|
||||
default:
|
||||
err = claudeDesktopSupported()
|
||||
}
|
||||
if err != nil {
|
||||
return claudeDesktopTargets{}, err
|
||||
}
|
||||
|
||||
return newClaudeDesktopTargets(normalRoots, thirdPartyRoots), nil
|
||||
}
|
||||
|
||||
func newClaudeDesktopTargets(normalRoots, thirdPartyRoots []string) claudeDesktopTargets {
|
||||
targets := claudeDesktopTargets{}
|
||||
for _, root := range claudeDesktopDedupePaths(normalRoots) {
|
||||
targets.normalConfigs = append(targets.normalConfigs, filepath.Join(root, "claude_desktop_config.json"))
|
||||
}
|
||||
for _, root := range claudeDesktopDedupePaths(thirdPartyRoots) {
|
||||
targets.thirdPartyProfiles = append(targets.thirdPartyProfiles, claudeDesktopThirdPartyPaths{
|
||||
desktopConfig: filepath.Join(root, "claude_desktop_config.json"),
|
||||
meta: filepath.Join(root, "configLibrary", "_meta.json"),
|
||||
profile: filepath.Join(root, "configLibrary", claudeDesktopProfileID+".json"),
|
||||
})
|
||||
}
|
||||
return targets
|
||||
}
|
||||
|
||||
func claudeDesktopTargetProfilePaths(targets claudeDesktopTargets) []string {
|
||||
paths := make([]string, 0, len(targets.thirdPartyProfiles))
|
||||
for _, target := range targets.thirdPartyProfiles {
|
||||
paths = append(paths, target.profile)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func claudeDesktopLocalAppData() (string, error) {
|
||||
if local := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); local != "" {
|
||||
return local, nil
|
||||
}
|
||||
if home := strings.TrimSpace(os.Getenv("USERPROFILE")); home != "" {
|
||||
return filepath.Join(home, "AppData", "Local"), nil
|
||||
}
|
||||
home, err := claudeDesktopUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "AppData", "Local"), nil
|
||||
}
|
||||
|
||||
type claudeDesktopAPIKeySource int
|
||||
|
||||
const (
|
||||
claudeDesktopAPIKeySourceNone claudeDesktopAPIKeySource = iota
|
||||
claudeDesktopAPIKeySourceEnv
|
||||
claudeDesktopAPIKeySourceProfile
|
||||
)
|
||||
|
||||
func claudeDesktopValidatedAPIKey(ctx context.Context, profilePaths []string) (string, error) {
|
||||
key, source, err := claudeDesktopAPIKey(profilePaths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := claudeDesktopValidateAPIKey(ctx, key); err == nil {
|
||||
return key, nil
|
||||
} else if source != claudeDesktopAPIKeySourceProfile || !canPromptClaudeDesktopAPIKey() {
|
||||
return "", err
|
||||
}
|
||||
return promptValidClaudeDesktopAPIKey(ctx)
|
||||
}
|
||||
|
||||
func claudeDesktopAPIKey(profilePaths []string) (string, claudeDesktopAPIKeySource, error) {
|
||||
if key := strings.TrimSpace(os.Getenv("OLLAMA_API_KEY")); key != "" {
|
||||
return key, claudeDesktopAPIKeySourceEnv, nil
|
||||
}
|
||||
for _, profilePath := range profilePaths {
|
||||
if key := readClaudeDesktopGatewayAPIKey(profilePath); key != "" {
|
||||
return key, claudeDesktopAPIKeySourceProfile, nil
|
||||
}
|
||||
}
|
||||
key, err := promptClaudeDesktopAPIKeyValue()
|
||||
return key, claudeDesktopAPIKeySourceNone, err
|
||||
}
|
||||
|
||||
func canPromptClaudeDesktopAPIKey() bool {
|
||||
return isInteractiveSession() && !currentLaunchConfirmPolicy.requireYesMessage
|
||||
}
|
||||
|
||||
func promptValidClaudeDesktopAPIKey(ctx context.Context) (string, error) {
|
||||
key, err := promptClaudeDesktopAPIKeyValue()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := claudeDesktopValidateAPIKey(ctx, key); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func promptClaudeDesktopAPIKeyValue() (string, error) {
|
||||
if !canPromptClaudeDesktopAPIKey() {
|
||||
return "", missingClaudeDesktopAPIKeyError()
|
||||
}
|
||||
key, err := claudeDesktopPromptAPIKey()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return "", missingClaudeDesktopAPIKeyError()
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func missingClaudeDesktopAPIKeyError() error {
|
||||
return fmt.Errorf("OLLAMA_API_KEY is required for Claude Desktop. Create an API key at %s, then re-run with OLLAMA_API_KEY set", claudeDesktopAPIKeyURL)
|
||||
}
|
||||
|
||||
func promptClaudeDesktopAPIKey() (string, error) {
|
||||
fmt.Fprint(os.Stderr, claudeDesktopAPIKeyPrompt())
|
||||
key, err := term.ReadPassword(int(os.Stdin.Fd()))
|
||||
fmt.Fprintln(os.Stderr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(key), nil
|
||||
}
|
||||
|
||||
func claudeDesktopAPIKeyPrompt() string {
|
||||
return fmt.Sprintf("Create an Ollama API key at %s\nEnter Ollama API key (input hidden): ", claudeDesktopAPIKeyURL)
|
||||
}
|
||||
|
||||
func readClaudeDesktopGatewayAPIKey(path string) string {
|
||||
cfg, err := readClaudeDesktopJSON(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
key, _ := cfg["inferenceGatewayApiKey"].(string)
|
||||
return strings.TrimSpace(key)
|
||||
}
|
||||
|
||||
func validateClaudeDesktopAPIKey(ctx context.Context, key string) error {
|
||||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if claudeDesktopAPIKeyHasInvalidHeaderChars(key) {
|
||||
return claudeDesktopAPIKeyVerificationError()
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, claudeDesktopGatewayBaseURL+"/v1/models", nil)
|
||||
if err != nil {
|
||||
return claudeDesktopAPIKeyVerificationError()
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := claudeDesktopHTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return claudeDesktopAPIKeyVerificationError()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4<<10))
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden:
|
||||
return fmt.Errorf("Ollama API key was rejected; create a valid key at %s", claudeDesktopAPIKeyURL)
|
||||
case resp.StatusCode >= 200 && resp.StatusCode < 300:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("could not verify Ollama API key; ollama.com returned status %d, try again later", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func claudeDesktopAPIKeyHasInvalidHeaderChars(key string) bool {
|
||||
return strings.ContainsFunc(key, func(r rune) bool {
|
||||
return r < ' ' || r == 0x7f
|
||||
})
|
||||
}
|
||||
|
||||
func claudeDesktopAPIKeyVerificationError() error {
|
||||
return fmt.Errorf("could not verify Ollama API key; copy a key from %s and try again", claudeDesktopAPIKeyURL)
|
||||
}
|
||||
|
||||
func writeClaudeDesktopDeploymentMode(path, mode string) error {
|
||||
cfg, err := readClaudeDesktopJSONAllowMissing(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse Claude Desktop config: %w", err)
|
||||
}
|
||||
cfg["deploymentMode"] = mode
|
||||
return writeClaudeDesktopJSON(path, cfg)
|
||||
}
|
||||
|
||||
func writeClaudeDesktopMeta(path, id, name string) error {
|
||||
meta, err := readClaudeDesktopJSONAllowMissing(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse Claude Desktop config metadata: %w", err)
|
||||
}
|
||||
|
||||
meta["appliedId"] = id
|
||||
entries := make([]any, 0)
|
||||
for _, entry := range claudeDesktopAnySlice(meta["entries"]) {
|
||||
entryMap, _ := entry.(map[string]any)
|
||||
if entryMap == nil {
|
||||
entries = append(entries, entry)
|
||||
continue
|
||||
}
|
||||
if entryID, _ := entryMap["id"].(string); entryID == id {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, entryMap)
|
||||
}
|
||||
entries = append(entries, map[string]any{
|
||||
"id": id,
|
||||
"name": name,
|
||||
})
|
||||
meta["entries"] = entries
|
||||
return writeClaudeDesktopJSON(path, meta)
|
||||
}
|
||||
|
||||
func writeClaudeDesktopGatewayProfile(path string, apiKey string, forceChooser bool) error {
|
||||
cfg, err := readClaudeDesktopJSONAllowMissing(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse Claude Desktop Ollama profile: %w", err)
|
||||
}
|
||||
cfg["inferenceProvider"] = "gateway"
|
||||
cfg["inferenceGatewayBaseUrl"] = claudeDesktopGatewayBaseURL
|
||||
cfg["inferenceGatewayApiKey"] = apiKey
|
||||
cfg["inferenceGatewayAuthScheme"] = "bearer"
|
||||
delete(cfg, "inferenceModels")
|
||||
cfg["disableDeploymentModeChooser"] = forceChooser
|
||||
return writeClaudeDesktopJSON(path, cfg)
|
||||
}
|
||||
|
||||
func restoreClaudeDesktopMeta(path string) error {
|
||||
meta, err := readClaudeDesktopJSONAllowMissing(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse Claude Desktop config metadata: %w", err)
|
||||
}
|
||||
if len(meta) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
if appliedID, _ := meta["appliedId"].(string); appliedID == claudeDesktopProfileID {
|
||||
delete(meta, "appliedId")
|
||||
changed = true
|
||||
}
|
||||
|
||||
entries := claudeDesktopAnySlice(meta["entries"])
|
||||
if entries != nil {
|
||||
filtered := make([]any, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entryMap, _ := entry.(map[string]any)
|
||||
if entryID, _ := entryMap["id"].(string); entryID == claudeDesktopProfileID {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
meta["entries"] = filtered
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
return writeClaudeDesktopJSON(path, meta)
|
||||
}
|
||||
|
||||
func restoreClaudeDesktopOllamaProfile(path string) error {
|
||||
cfg, err := readClaudeDesktopJSONAllowMissing(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse Claude Desktop Ollama profile: %w", err)
|
||||
}
|
||||
if len(cfg) == 0 {
|
||||
return nil
|
||||
}
|
||||
cfg["disableDeploymentModeChooser"] = false
|
||||
delete(cfg, "inferenceProvider")
|
||||
delete(cfg, "inferenceGatewayBaseUrl")
|
||||
delete(cfg, "inferenceGatewayAuthScheme")
|
||||
delete(cfg, "inferenceModels")
|
||||
return writeClaudeDesktopJSON(path, cfg)
|
||||
}
|
||||
|
||||
func readClaudeDesktopAppliedID(path string) string {
|
||||
meta, err := readClaudeDesktopJSON(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
applied, _ := meta["appliedId"].(string)
|
||||
return applied
|
||||
}
|
||||
|
||||
func readClaudeDesktopDeploymentMode(path string) string {
|
||||
cfg, err := readClaudeDesktopJSON(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
mode, _ := cfg["deploymentMode"].(string)
|
||||
return mode
|
||||
}
|
||||
|
||||
func claudeDesktopTargetsConfigured(targets claudeDesktopTargets) bool {
|
||||
if len(targets.normalConfigs) == 0 || len(targets.thirdPartyProfiles) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, path := range targets.normalConfigs {
|
||||
if readClaudeDesktopDeploymentMode(path) != "3p" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, target := range targets.thirdPartyProfiles {
|
||||
if readClaudeDesktopDeploymentMode(target.desktopConfig) != "3p" {
|
||||
return false
|
||||
}
|
||||
if !claudeDesktopThirdPartyProfileConfigured(target) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func claudeDesktopThirdPartyProfileConfigured(target claudeDesktopThirdPartyPaths) bool {
|
||||
if readClaudeDesktopAppliedID(target.meta) != claudeDesktopProfileID {
|
||||
return false
|
||||
}
|
||||
|
||||
cfg, err := readClaudeDesktopJSON(target.profile)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if s, _ := cfg["inferenceProvider"].(string); s != "gateway" {
|
||||
return false
|
||||
}
|
||||
if s, _ := cfg["inferenceGatewayBaseUrl"].(string); strings.TrimRight(s, "/") != claudeDesktopGatewayBaseURL {
|
||||
return false
|
||||
}
|
||||
if s, _ := cfg["inferenceGatewayApiKey"].(string); strings.TrimSpace(s) == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func readClaudeDesktopJSONAllowMissing(path string) (map[string]any, error) {
|
||||
cfg, err := readClaudeDesktopJSON(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func readClaudeDesktopJSON(path string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = map[string]any{}
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func writeClaudeDesktopJSON(path string, cfg any) error {
|
||||
data, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(path, data)
|
||||
}
|
||||
|
||||
func claudeDesktopAnySlice(value any) []any {
|
||||
switch v := value.(type) {
|
||||
case []any:
|
||||
return v
|
||||
case nil:
|
||||
return nil
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func claudeDesktopLaunchOrRestart(prompt string) error {
|
||||
if !claudeDesktopIsRunning() {
|
||||
return claudeDesktopOpenApp()
|
||||
}
|
||||
restartAppPath := ""
|
||||
if claudeDesktopGOOS == "windows" {
|
||||
restartAppPath = claudeDesktopRunningAppPath()
|
||||
}
|
||||
|
||||
restart, err := ConfirmPrompt(prompt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !restart {
|
||||
fmt.Fprintln(os.Stderr, "\nQuit and reopen Claude Desktop when you're ready for the profile change to take effect.")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := claudeDesktopQuitApp(); err != nil {
|
||||
return fmt.Errorf("quit Claude Desktop: %w", err)
|
||||
}
|
||||
if err := waitForClaudeDesktopExit(30 * time.Second); err != nil {
|
||||
return err
|
||||
}
|
||||
if restartAppPath != "" {
|
||||
return claudeDesktopOpenAppPath(restartAppPath)
|
||||
}
|
||||
return claudeDesktopOpenApp()
|
||||
}
|
||||
|
||||
func waitForClaudeDesktopExit(timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !claudeDesktopIsRunning() {
|
||||
return nil
|
||||
}
|
||||
claudeDesktopSleep(200 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("Claude Desktop did not quit; quit it manually and re-run the command")
|
||||
}
|
||||
|
||||
func defaultClaudeDesktopIsRunning() bool {
|
||||
switch claudeDesktopGOOS {
|
||||
case "darwin":
|
||||
out, err := exec.Command("pgrep", "-f", "Claude.app/Contents/MacOS/Claude").Output()
|
||||
return err == nil && strings.TrimSpace(string(out)) != ""
|
||||
case "windows":
|
||||
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", `(Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | Select-Object -First 1).Id`).Output()
|
||||
return err == nil && strings.TrimSpace(string(out)) != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func defaultClaudeDesktopOpenApp() error {
|
||||
switch claudeDesktopGOOS {
|
||||
case "windows":
|
||||
if path := claudeDesktopAppPath(); path != "" {
|
||||
return claudeDesktopOpenAppPath(path)
|
||||
}
|
||||
if path := claudeDesktopRunningAppPath(); path != "" {
|
||||
return claudeDesktopOpenAppPath(path)
|
||||
}
|
||||
return fmt.Errorf("Claude Desktop executable was not found; open Claude Desktop manually once and re-run 'ollama launch claude-desktop --restore'")
|
||||
case "darwin":
|
||||
return openClaudeDesktopDarwin()
|
||||
default:
|
||||
return claudeDesktopSupported()
|
||||
}
|
||||
}
|
||||
|
||||
func defaultClaudeDesktopOpenAppPath(path string) error {
|
||||
switch claudeDesktopGOOS {
|
||||
case "windows":
|
||||
return exec.Command("powershell.exe", "-NoProfile", "-Command", "Start-Process -FilePath "+quotePowerShellString(path)).Run()
|
||||
case "darwin":
|
||||
return openClaudeDesktopDarwin()
|
||||
default:
|
||||
return claudeDesktopSupported()
|
||||
}
|
||||
}
|
||||
|
||||
func openClaudeDesktopDarwin() error {
|
||||
cmd := exec.Command("open", "-a", "Claude")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func defaultClaudeDesktopRunningAppPath() string {
|
||||
if claudeDesktopGOOS != "windows" {
|
||||
return ""
|
||||
}
|
||||
script := `(Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 -and $_.Path } | Select-Object -First 1 -ExpandProperty Path)`
|
||||
out, err := exec.Command("powershell.exe", "-NoProfile", "-Command", script).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func defaultClaudeDesktopQuitApp() error {
|
||||
if claudeDesktopGOOS == "windows" {
|
||||
script := `Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.MainWindowHandle -ne 0 } | ForEach-Object { [void]$_.CloseMainWindow() }`
|
||||
return exec.Command("powershell.exe", "-NoProfile", "-Command", script).Run()
|
||||
}
|
||||
return exec.Command("osascript", "-e", `tell application "Claude" to quit`).Run()
|
||||
}
|
||||
|
||||
func quotePowerShellString(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func withClaudeDesktopPlatform(t *testing.T, goos string) {
|
||||
t.Helper()
|
||||
old := claudeDesktopGOOS
|
||||
claudeDesktopGOOS = goos
|
||||
t.Cleanup(func() {
|
||||
claudeDesktopGOOS = old
|
||||
})
|
||||
}
|
||||
|
||||
func withClaudeDesktopValidation(t *testing.T, fn func(context.Context, string) error) {
|
||||
t.Helper()
|
||||
old := claudeDesktopValidateAPIKey
|
||||
claudeDesktopValidateAPIKey = fn
|
||||
t.Cleanup(func() {
|
||||
claudeDesktopValidateAPIKey = old
|
||||
})
|
||||
}
|
||||
|
||||
func withClaudeDesktopPrompt(t *testing.T, fn func() (string, error)) {
|
||||
t.Helper()
|
||||
old := claudeDesktopPromptAPIKey
|
||||
claudeDesktopPromptAPIKey = fn
|
||||
t.Cleanup(func() {
|
||||
claudeDesktopPromptAPIKey = old
|
||||
})
|
||||
}
|
||||
|
||||
func withClaudeDesktopProcessHooks(t *testing.T, running func() bool, quit func() error, open func() error) {
|
||||
t.Helper()
|
||||
oldRunning := claudeDesktopIsRunning
|
||||
oldQuit := claudeDesktopQuitApp
|
||||
oldOpen := claudeDesktopOpenApp
|
||||
oldOpenPath := claudeDesktopOpenAppPath
|
||||
oldRunningPath := claudeDesktopRunningAppPath
|
||||
oldSleep := claudeDesktopSleep
|
||||
claudeDesktopIsRunning = running
|
||||
claudeDesktopQuitApp = quit
|
||||
claudeDesktopOpenApp = open
|
||||
claudeDesktopOpenAppPath = oldOpenPath
|
||||
claudeDesktopRunningAppPath = oldRunningPath
|
||||
claudeDesktopSleep = func(time.Duration) {}
|
||||
t.Cleanup(func() {
|
||||
claudeDesktopIsRunning = oldRunning
|
||||
claudeDesktopQuitApp = oldQuit
|
||||
claudeDesktopOpenApp = oldOpen
|
||||
claudeDesktopOpenAppPath = oldOpenPath
|
||||
claudeDesktopRunningAppPath = oldRunningPath
|
||||
claudeDesktopSleep = oldSleep
|
||||
})
|
||||
}
|
||||
|
||||
func claudeDesktopReadJSON(t *testing.T, path string) map[string]any {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("parse %s: %v", path, err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func TestClaudeDesktopIntegration(t *testing.T) {
|
||||
c := &ClaudeDesktop{}
|
||||
|
||||
t.Run("implements Runner", func(t *testing.T) {
|
||||
var _ Runner = c
|
||||
})
|
||||
t.Run("implements managed autodiscovery integration", func(t *testing.T) {
|
||||
var _ ManagedAutodiscoveryIntegration = c
|
||||
})
|
||||
t.Run("does not use local Ollama Cloud auth gate", func(t *testing.T) {
|
||||
if _, ok := any(c).(ManagedAutodiscoveryCloudIntegration); ok {
|
||||
t.Fatal("Claude Desktop should validate OLLAMA_API_KEY directly instead of requiring local Ollama Cloud sign-in")
|
||||
}
|
||||
})
|
||||
t.Run("implements restore", func(t *testing.T) {
|
||||
var _ RestorableIntegration = c
|
||||
})
|
||||
t.Run("has restore hint", func(t *testing.T) {
|
||||
var _ RestoreHintIntegration = c
|
||||
if !strings.Contains(c.RestoreHint(), "--restore") {
|
||||
t.Fatalf("expected restore hint to mention --restore, got %q", c.RestoreHint())
|
||||
}
|
||||
if strings.Contains(c.RestoreHint(), "Tip:") {
|
||||
t.Fatalf("restore hint should not use Tip wording, got %q", c.RestoreHint())
|
||||
}
|
||||
})
|
||||
t.Run("has success messages", func(t *testing.T) {
|
||||
var _ ConfigurationSuccessIntegration = c
|
||||
var _ RestoreSuccessIntegration = c
|
||||
if got := c.ConfigurationSuccessMessage(); got != "Claude Desktop profile changed to Ollama Cloud.\nTo restore the usual Claude profile, run: ollama launch claude-desktop --restore" {
|
||||
t.Fatalf("configuration success message = %q", got)
|
||||
}
|
||||
if got := c.RestoreSuccessMessage(); got != "Claude Desktop restored to the usual Claude profile." {
|
||||
t.Fatalf("restore success message = %q", got)
|
||||
}
|
||||
})
|
||||
t.Run("skips local model readiness", func(t *testing.T) {
|
||||
var _ ManagedModelReadinessSkipper = c
|
||||
if !c.SkipModelReadiness() {
|
||||
t.Fatal("expected Claude Desktop to skip local model readiness")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ClaudeDesktopLaunchReturnsUnsupported(t *testing.T) {
|
||||
for _, name := range []string{"claude-desktop", "claude-app"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: name})
|
||||
if err == nil {
|
||||
t.Fatal("expected Claude Desktop launch to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Claude Desktop is no longer supported") {
|
||||
t.Fatalf("expected unsupported guidance, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ollama launch claude-desktop --restore") {
|
||||
t.Fatalf("expected restore guidance, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegration_ClaudeDesktopRestoreStillWorks(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(tmpDir, "Applications", "Claude.app"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stderr := captureStderr(t, func() {
|
||||
err = LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "claude-desktop", Restore: true})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LaunchIntegration restore returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr, claudeDesktopRestoredMessage) {
|
||||
t.Fatalf("expected restore success message, got stderr: %q", stderr)
|
||||
}
|
||||
desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig)
|
||||
if desktopConfig["deploymentMode"] != "1p" {
|
||||
t.Fatalf("deploymentMode = %v, want 1p", desktopConfig["deploymentMode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopConfigureWritesOllamaCloudProfile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||
|
||||
var validatedKey string
|
||||
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
|
||||
validatedKey = key
|
||||
return nil
|
||||
})
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.desktopConfig), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.meta), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.desktopConfig, []byte(`{"existing":true}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.meta, []byte(`{"entries":[{"id":"custom","name":"Custom"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||
t.Fatalf("Configure returned error: %v", err)
|
||||
}
|
||||
if validatedKey != "test-api-key" {
|
||||
t.Fatalf("validated key = %q, want test API key", validatedKey)
|
||||
}
|
||||
|
||||
desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig)
|
||||
if desktopConfig["existing"] != true {
|
||||
t.Fatalf("existing desktop config key was not preserved: %v", desktopConfig)
|
||||
}
|
||||
if desktopConfig["deploymentMode"] != "3p" {
|
||||
t.Fatalf("deploymentMode = %v, want 3p", desktopConfig["deploymentMode"])
|
||||
}
|
||||
normalConfig := claudeDesktopReadJSON(t, paths.normalConfig)
|
||||
if normalConfig["deploymentMode"] != "3p" {
|
||||
t.Fatalf("normal deploymentMode = %v, want 3p", normalConfig["deploymentMode"])
|
||||
}
|
||||
|
||||
meta := claudeDesktopReadJSON(t, paths.meta)
|
||||
if meta["appliedId"] != claudeDesktopProfileID {
|
||||
t.Fatalf("appliedId = %v, want %s", meta["appliedId"], claudeDesktopProfileID)
|
||||
}
|
||||
entries, _ := meta["entries"].([]any)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("entries len = %d, want 2: %v", len(entries), entries)
|
||||
}
|
||||
|
||||
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||
if profile["inferenceProvider"] != "gateway" {
|
||||
t.Fatalf("inferenceProvider = %v, want gateway", profile["inferenceProvider"])
|
||||
}
|
||||
if profile["inferenceGatewayBaseUrl"] != claudeDesktopGatewayBaseURL {
|
||||
t.Fatalf("base URL = %v, want %s", profile["inferenceGatewayBaseUrl"], claudeDesktopGatewayBaseURL)
|
||||
}
|
||||
if profile["inferenceGatewayApiKey"] != "test-api-key" {
|
||||
t.Fatal("expected configured API key to be written")
|
||||
}
|
||||
if profile["inferenceGatewayAuthScheme"] != "bearer" {
|
||||
t.Fatalf("auth scheme = %v, want bearer", profile["inferenceGatewayAuthScheme"])
|
||||
}
|
||||
if profile["disableDeploymentModeChooser"] != true {
|
||||
t.Fatalf("disableDeploymentModeChooser = %v, want true", profile["disableDeploymentModeChooser"])
|
||||
}
|
||||
if _, ok := profile["inferenceModels"]; ok {
|
||||
t.Fatalf("inferenceModels should be omitted so Claude can discover models, got %v", profile["inferenceModels"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopConfigureAutodiscoveryRemovesExistingModelCatalog(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.profile, []byte(`{"inferenceModels":["qwen3.5"],"inferenceGatewayApiKey":"old"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||
}
|
||||
|
||||
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||
if _, ok := profile["inferenceModels"]; ok {
|
||||
t.Fatalf("inferenceModels should be removed, got %v", profile["inferenceModels"])
|
||||
}
|
||||
if profile["inferenceGatewayApiKey"] != "test-api-key" {
|
||||
t.Fatal("expected env API key to replace the old key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopWindowsConfigPathsUseLocalAppData(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filepath.Join(tmpDir, "LocalAppData", "Claude-3p", "claude_desktop_config.json"); paths.desktopConfig != want {
|
||||
t.Fatalf("desktop config = %q, want %q", paths.desktopConfig, want)
|
||||
}
|
||||
if want := filepath.Join(tmpDir, "LocalAppData", "Claude", "claude_desktop_config.json"); paths.normalConfig != want {
|
||||
t.Fatalf("normal config = %q, want %q", paths.normalConfig, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopWindowsConfigPathsFallbackToNestProfile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
local := filepath.Join(tmpDir, "LocalAppData")
|
||||
t.Setenv("LOCALAPPDATA", local)
|
||||
if err := os.MkdirAll(filepath.Join(local, "Claude Nest-3p"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := filepath.Join(local, "Claude Nest-3p", "claude_desktop_config.json"); paths.desktopConfig != want {
|
||||
t.Fatalf("desktop config = %q, want %q", paths.desktopConfig, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopAutodiscoveryConfiguredOnWindows(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||
|
||||
c := &ClaudeDesktop{}
|
||||
if err := c.ConfigureAutodiscovery(); err != nil {
|
||||
t.Fatalf("Configure returned error: %v", err)
|
||||
}
|
||||
if !c.AutodiscoveryConfigured() {
|
||||
t.Fatal("expected Claude Desktop autodiscovery config to be detected on Windows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopConfigureAutodiscoveryTouchesAllWindowsProfileCandidates(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
local := filepath.Join(tmpDir, "LocalAppData")
|
||||
t.Setenv("LOCALAPPDATA", local)
|
||||
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||
|
||||
targets, err := claudeDesktopTargetPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(targets.normalConfigs) != 2 {
|
||||
t.Fatalf("normal config target count = %d, want 2", len(targets.normalConfigs))
|
||||
}
|
||||
if len(targets.thirdPartyProfiles) != 2 {
|
||||
t.Fatalf("third-party target count = %d, want 2", len(targets.thirdPartyProfiles))
|
||||
}
|
||||
|
||||
c := &ClaudeDesktop{}
|
||||
if err := c.ConfigureAutodiscovery(); err != nil {
|
||||
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||
}
|
||||
|
||||
for _, path := range targets.normalConfigs {
|
||||
cfg := claudeDesktopReadJSON(t, path)
|
||||
if cfg["deploymentMode"] != "3p" {
|
||||
t.Fatalf("%s deploymentMode = %v, want 3p", path, cfg["deploymentMode"])
|
||||
}
|
||||
}
|
||||
for _, target := range targets.thirdPartyProfiles {
|
||||
cfg := claudeDesktopReadJSON(t, target.desktopConfig)
|
||||
if cfg["deploymentMode"] != "3p" {
|
||||
t.Fatalf("%s deploymentMode = %v, want 3p", target.desktopConfig, cfg["deploymentMode"])
|
||||
}
|
||||
meta := claudeDesktopReadJSON(t, target.meta)
|
||||
if meta["appliedId"] != claudeDesktopProfileID {
|
||||
t.Fatalf("%s appliedId = %v, want %s", target.meta, meta["appliedId"], claudeDesktopProfileID)
|
||||
}
|
||||
profile := claudeDesktopReadJSON(t, target.profile)
|
||||
if profile["inferenceProvider"] != "gateway" {
|
||||
t.Fatalf("%s inferenceProvider = %v, want gateway", target.profile, profile["inferenceProvider"])
|
||||
}
|
||||
if profile["inferenceGatewayBaseUrl"] != claudeDesktopGatewayBaseURL {
|
||||
t.Fatalf("%s base URL = %v, want %s", target.profile, profile["inferenceGatewayBaseUrl"], claudeDesktopGatewayBaseURL)
|
||||
}
|
||||
if profile["inferenceGatewayApiKey"] != "test-api-key" {
|
||||
t.Fatalf("%s should contain the configured API key", target.profile)
|
||||
}
|
||||
if _, ok := profile["inferenceModels"]; ok {
|
||||
t.Fatalf("%s inferenceModels should be omitted, got %v", target.profile, profile["inferenceModels"])
|
||||
}
|
||||
}
|
||||
if !c.AutodiscoveryConfigured() {
|
||||
t.Fatal("expected all Windows profile candidates to be considered configured")
|
||||
}
|
||||
|
||||
if err := writeClaudeDesktopDeploymentMode(targets.thirdPartyProfiles[1].desktopConfig, "1p"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.AutodiscoveryConfigured() {
|
||||
t.Fatal("expected a stale Windows candidate to force reconfiguration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopInstalledOnWindowsRecognizesLocalProfileDir(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
local := filepath.Join(tmpDir, "LocalAppData")
|
||||
t.Setenv("LOCALAPPDATA", local)
|
||||
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
|
||||
if err := os.MkdirAll(filepath.Join(local, "Claude-3p"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !claudeDesktopInstalled() {
|
||||
t.Fatal("expected Claude Desktop to be installed when the Windows profile directory exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopWindowsAppPathFindsAnthropicClaudeInstall(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
local := filepath.Join(tmpDir, "LocalAppData")
|
||||
t.Setenv("LOCALAPPDATA", local)
|
||||
want := filepath.Join(local, "AnthropicClaude", "app-1.2.3", "Claude.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(want), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(want, []byte(""), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := claudeDesktopAppPath(); got != want {
|
||||
t.Fatalf("claudeDesktopAppPath() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForClaudeDesktopExitUsesRunningHook(t *testing.T) {
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
runningChecks := 0
|
||||
withClaudeDesktopProcessHooks(t,
|
||||
func() bool {
|
||||
runningChecks++
|
||||
return runningChecks == 1
|
||||
},
|
||||
func() error { return nil },
|
||||
func() error { return nil },
|
||||
)
|
||||
|
||||
if err := waitForClaudeDesktopExit(time.Second); err != nil {
|
||||
t.Fatalf("waitForClaudeDesktopExit returned error: %v", err)
|
||||
}
|
||||
if runningChecks < 2 {
|
||||
t.Fatalf("expected running hook to be checked until the visible window exits, got %d checks", runningChecks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopWindowsRestoreRestartUsesCapturedDesktopPath(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||
restoreConfirm := withLaunchConfirmPolicy(launchConfirmPolicy{yes: true})
|
||||
defer restoreConfirm()
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
desktopPath := `C:\Users\parth\AppData\Local\AnthropicClaude\app-1.2.3\Claude.exe`
|
||||
running := true
|
||||
var openedPath string
|
||||
withClaudeDesktopProcessHooks(t,
|
||||
func() bool { return running },
|
||||
func() error {
|
||||
running = false
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
t.Fatal("expected restart to open the captured Desktop executable path, not the generic launcher")
|
||||
return nil
|
||||
},
|
||||
)
|
||||
claudeDesktopRunningAppPath = func() string { return desktopPath }
|
||||
claudeDesktopOpenAppPath = func(path string) error {
|
||||
openedPath = path
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := (&ClaudeDesktop{}).Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
if openedPath != desktopPath {
|
||||
t.Fatalf("opened path = %q, want %q", openedPath, desktopPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopWindowsOpenDoesNotFallBackToClaudeCommand(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||
|
||||
oldRunningPath := claudeDesktopRunningAppPath
|
||||
claudeDesktopRunningAppPath = func() string { return "" }
|
||||
t.Cleanup(func() { claudeDesktopRunningAppPath = oldRunningPath })
|
||||
|
||||
err := defaultClaudeDesktopOpenApp()
|
||||
if err == nil || !strings.Contains(err.Error(), "Claude Desktop executable was not found") {
|
||||
t.Fatalf("defaultClaudeDesktopOpenApp error = %v, want executable-not-found error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopConfigureStopsBeforeWriteWhenKeyValidationFails(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
t.Setenv("OLLAMA_API_KEY", "bad-key")
|
||||
withClaudeDesktopValidation(t, func(context.Context, string) error {
|
||||
return errors.New("invalid key")
|
||||
})
|
||||
|
||||
err := (&ClaudeDesktop{}).ConfigureAutodiscovery()
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid key") {
|
||||
t.Fatalf("Configure error = %v, want invalid key", err)
|
||||
}
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(paths.desktopConfig); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("desktop config should not be written after validation failure, stat err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClaudeDesktopAPIKeyUsesClaudeModelsRoute(t *testing.T) {
|
||||
oldClient := claudeDesktopHTTPClient
|
||||
var gotPath, gotAuth string
|
||||
claudeDesktopHTTPClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
gotPath = req.URL.Path
|
||||
gotAuth = req.Header.Get("Authorization")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[]}`)),
|
||||
Header: make(http.Header),
|
||||
}, nil
|
||||
})}
|
||||
t.Cleanup(func() {
|
||||
claudeDesktopHTTPClient = oldClient
|
||||
})
|
||||
|
||||
if err := validateClaudeDesktopAPIKey(context.Background(), "test-key"); err != nil {
|
||||
t.Fatalf("validateClaudeDesktopAPIKey returned error: %v", err)
|
||||
}
|
||||
if gotPath != "/v1/models" {
|
||||
t.Fatalf("validation path = %q, want /v1/models", gotPath)
|
||||
}
|
||||
if gotAuth != "Bearer test-key" {
|
||||
t.Fatalf("Authorization header = %q, want bearer key", gotAuth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClaudeDesktopAPIKeyHidesInvalidHeaderDetails(t *testing.T) {
|
||||
err := validateClaudeDesktopAPIKey(context.Background(), "bad\nkey")
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for key with newline")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could not verify Ollama API key") {
|
||||
t.Fatalf("validation error = %v, want friendly verification message", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "invalid header") || strings.Contains(err.Error(), "net/http") {
|
||||
t.Fatalf("validation error should not expose transport internals: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "https://ollama.com/settings/keys") {
|
||||
t.Fatalf("validation error should include settings link: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopConfigureRequiresAPIKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
t.Setenv("OLLAMA_API_KEY", "")
|
||||
withClaudeDesktopValidation(t, func(context.Context, string) error {
|
||||
t.Fatal("validation should not run without an API key")
|
||||
return nil
|
||||
})
|
||||
|
||||
err := (&ClaudeDesktop{}).ConfigureAutodiscovery()
|
||||
if err == nil || !strings.Contains(err.Error(), "OLLAMA_API_KEY is required") {
|
||||
t.Fatalf("Configure error = %v, want missing key guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopAPIKeyPromptIncludesSettingsLink(t *testing.T) {
|
||||
prompt := claudeDesktopAPIKeyPrompt()
|
||||
if !strings.Contains(prompt, "Enter Ollama API key") {
|
||||
t.Fatalf("prompt should ask for the API key, got %q", prompt)
|
||||
}
|
||||
if !strings.Contains(prompt, "https://ollama.com/settings/keys") {
|
||||
t.Fatalf("prompt should include API key settings link, got %q", prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopConfigureReusesExistingAPIKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
t.Setenv("OLLAMA_API_KEY", "")
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.profile, []byte(`{"inferenceGatewayApiKey":"existing-key"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var validatedKey string
|
||||
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
|
||||
validatedKey = key
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||
}
|
||||
if validatedKey != "existing-key" {
|
||||
t.Fatalf("validated key = %q, want existing-key", validatedKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopConfigureReplacesInvalidExistingAPIKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
withInteractiveSession(t, true)
|
||||
t.Setenv("OLLAMA_API_KEY", "")
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.profile, []byte(`{"inferenceGatewayApiKey":"stale-key"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var validated []string
|
||||
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
|
||||
validated = append(validated, key)
|
||||
if key == "stale-key" {
|
||||
return errors.New("invalid key")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
withClaudeDesktopPrompt(t, func() (string, error) {
|
||||
return "replacement-key", nil
|
||||
})
|
||||
|
||||
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||
}
|
||||
if diff := compareStrings(validated, []string{"stale-key", "replacement-key"}); diff != "" {
|
||||
t.Fatalf("validated keys mismatch: %s", diff)
|
||||
}
|
||||
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||
if profile["inferenceGatewayApiKey"] != "replacement-key" {
|
||||
t.Fatalf("configured key = %v, want replacement-key", profile["inferenceGatewayApiKey"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopConfigureReusesExistingAPIKeyFromAnyWindowsProfile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
local := filepath.Join(tmpDir, "LocalAppData")
|
||||
t.Setenv("LOCALAPPDATA", local)
|
||||
t.Setenv("OLLAMA_API_KEY", "")
|
||||
|
||||
targets, err := claudeDesktopTargetPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fallbackProfile := targets.thirdPartyProfiles[1].profile
|
||||
if err := os.MkdirAll(filepath.Dir(fallbackProfile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fallbackProfile, []byte(`{"inferenceGatewayApiKey":"fallback-key"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var validatedKey string
|
||||
withClaudeDesktopValidation(t, func(_ context.Context, key string) error {
|
||||
validatedKey = key
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := (&ClaudeDesktop{}).ConfigureAutodiscovery(); err != nil {
|
||||
t.Fatalf("ConfigureAutodiscovery returned error: %v", err)
|
||||
}
|
||||
if validatedKey != "fallback-key" {
|
||||
t.Fatalf("validated key = %q, want fallback-key", validatedKey)
|
||||
}
|
||||
for _, target := range targets.thirdPartyProfiles {
|
||||
profile := claudeDesktopReadJSON(t, target.profile)
|
||||
if profile["inferenceGatewayApiKey"] != "fallback-key" {
|
||||
t.Fatalf("%s should reuse fallback key, got %v", target.profile, profile["inferenceGatewayApiKey"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopAutodiscoveryConfiguredRequiresAppliedOllamaProfile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||
|
||||
c := &ClaudeDesktop{}
|
||||
if err := c.ConfigureAutodiscovery(); err != nil {
|
||||
t.Fatalf("Configure returned error: %v", err)
|
||||
}
|
||||
if !c.AutodiscoveryConfigured() {
|
||||
t.Fatal("expected Claude Desktop autodiscovery config to be detected")
|
||||
}
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"custom"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.AutodiscoveryConfigured() {
|
||||
t.Fatal("expected another applied profile to hide Claude Desktop autodiscovery config")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopAutodiscoveryConfiguredRequiresAPIKey(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
t.Setenv("OLLAMA_API_KEY", "test-api-key")
|
||||
withClaudeDesktopValidation(t, func(context.Context, string) error { return nil })
|
||||
|
||||
c := &ClaudeDesktop{}
|
||||
if err := c.ConfigureAutodiscovery(); err != nil {
|
||||
t.Fatalf("Configure returned error: %v", err)
|
||||
}
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||
delete(profile, "inferenceGatewayApiKey")
|
||||
data, err := json.Marshal(profile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.profile, data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if c.AutodiscoveryConfigured() {
|
||||
t.Fatal("expected missing gateway API key to force Claude Desktop reconfiguration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopRestoreSwitchesBackToFirstPartyMode(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
|
||||
|
||||
paths, err := claudeDesktopConfigPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(paths.profile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(paths.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer","inferenceModels":["legacy"]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&ClaudeDesktop{}).Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
|
||||
desktopConfig := claudeDesktopReadJSON(t, paths.desktopConfig)
|
||||
if desktopConfig["deploymentMode"] != "1p" {
|
||||
t.Fatalf("deploymentMode = %v, want 1p", desktopConfig["deploymentMode"])
|
||||
}
|
||||
normalConfig := claudeDesktopReadJSON(t, paths.normalConfig)
|
||||
if normalConfig["deploymentMode"] != "1p" {
|
||||
t.Fatalf("normal deploymentMode = %v, want 1p", normalConfig["deploymentMode"])
|
||||
}
|
||||
profile := claudeDesktopReadJSON(t, paths.profile)
|
||||
if profile["disableDeploymentModeChooser"] != false {
|
||||
t.Fatalf("disableDeploymentModeChooser = %v, want false", profile["disableDeploymentModeChooser"])
|
||||
}
|
||||
if profile["inferenceGatewayApiKey"] != "keep" {
|
||||
t.Fatal("restore should leave existing Ollama profile credentials in place")
|
||||
}
|
||||
for _, key := range []string{"inferenceProvider", "inferenceGatewayBaseUrl", "inferenceGatewayAuthScheme", "inferenceModels"} {
|
||||
if _, ok := profile[key]; ok {
|
||||
t.Fatalf("restore should clear stale %s from the Ollama profile: %v", key, profile)
|
||||
}
|
||||
}
|
||||
meta := claudeDesktopReadJSON(t, paths.meta)
|
||||
if _, ok := meta["appliedId"]; ok {
|
||||
t.Fatalf("restore should clear the applied Ollama third-party profile: %v", meta)
|
||||
}
|
||||
if (&ClaudeDesktop{}).AutodiscoveryConfigured() {
|
||||
t.Fatal("restore should leave Claude Desktop autodiscovery unconfigured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopRestoreTouchesAllWindowsProfileCandidates(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withClaudeDesktopPlatform(t, "windows")
|
||||
local := filepath.Join(tmpDir, "LocalAppData")
|
||||
t.Setenv("LOCALAPPDATA", local)
|
||||
withClaudeDesktopProcessHooks(t, func() bool { return false }, func() error { return nil }, func() error { return nil })
|
||||
|
||||
targets, err := claudeDesktopTargetPaths()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(targets.normalConfigs) != 2 {
|
||||
t.Fatalf("normal config target count = %d, want 2", len(targets.normalConfigs))
|
||||
}
|
||||
if len(targets.thirdPartyProfiles) != 2 {
|
||||
t.Fatalf("third-party target count = %d, want 2", len(targets.thirdPartyProfiles))
|
||||
}
|
||||
for _, target := range targets.thirdPartyProfiles {
|
||||
if err := os.MkdirAll(filepath.Dir(target.profile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target.meta, []byte(`{"appliedId":"`+claudeDesktopProfileID+`","entries":[{"id":"`+claudeDesktopProfileID+`","name":"Ollama"}]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target.profile, []byte(`{"disableDeploymentModeChooser":true,"inferenceGatewayApiKey":"keep","inferenceProvider":"gateway","inferenceGatewayBaseUrl":"https://ollama.com","inferenceGatewayAuthScheme":"bearer","inferenceModels":["legacy"]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := (&ClaudeDesktop{}).Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
|
||||
for _, path := range targets.normalConfigs {
|
||||
cfg := claudeDesktopReadJSON(t, path)
|
||||
if cfg["deploymentMode"] != "1p" {
|
||||
t.Fatalf("%s deploymentMode = %v, want 1p", path, cfg["deploymentMode"])
|
||||
}
|
||||
}
|
||||
for _, target := range targets.thirdPartyProfiles {
|
||||
cfg := claudeDesktopReadJSON(t, target.desktopConfig)
|
||||
if cfg["deploymentMode"] != "1p" {
|
||||
t.Fatalf("%s deploymentMode = %v, want 1p", target.desktopConfig, cfg["deploymentMode"])
|
||||
}
|
||||
meta := claudeDesktopReadJSON(t, target.meta)
|
||||
if _, ok := meta["appliedId"]; ok {
|
||||
t.Fatalf("%s should not keep the Ollama applied profile: %v", target.meta, meta)
|
||||
}
|
||||
profile := claudeDesktopReadJSON(t, target.profile)
|
||||
if profile["disableDeploymentModeChooser"] != false {
|
||||
t.Fatalf("%s disableDeploymentModeChooser = %v, want false", target.profile, profile["disableDeploymentModeChooser"])
|
||||
}
|
||||
if profile["inferenceGatewayApiKey"] != "keep" {
|
||||
t.Fatalf("%s should preserve gateway API key", target.profile)
|
||||
}
|
||||
for _, key := range []string{"inferenceProvider", "inferenceGatewayBaseUrl", "inferenceGatewayAuthScheme", "inferenceModels"} {
|
||||
if _, ok := profile[key]; ok {
|
||||
t.Fatalf("%s should clear stale %s: %v", target.profile, key, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeDesktopRunReturnsUnsupported(t *testing.T) {
|
||||
withClaudeDesktopPlatform(t, "darwin")
|
||||
|
||||
withClaudeDesktopProcessHooks(t,
|
||||
func() bool {
|
||||
t.Fatal("Run should not inspect Claude Desktop process state")
|
||||
return false
|
||||
},
|
||||
func() error {
|
||||
t.Fatal("Run should not quit Claude Desktop")
|
||||
return nil
|
||||
},
|
||||
func() error {
|
||||
t.Fatal("Run should not open Claude Desktop")
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
for _, args := range [][]string{nil, {"--foo"}} {
|
||||
err := (&ClaudeDesktop{}).Run("qwen3.5", nil, args)
|
||||
if err == nil {
|
||||
t.Fatal("expected Run to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Claude Desktop is no longer supported") {
|
||||
t.Fatalf("expected unsupported guidance, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ollama launch claude-desktop --restore") {
|
||||
t.Fatalf("expected restore guidance, got %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
-22
@@ -6,41 +6,56 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
const clineLaunchProvider = "ollama"
|
||||
|
||||
// Cline implements Runner and Editor for the Cline CLI integration
|
||||
type Cline struct{}
|
||||
|
||||
func (c *Cline) String() string { return "Cline" }
|
||||
|
||||
func (c *Cline) Run(model string, 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")
|
||||
}
|
||||
|
||||
cmd := exec.Command("cline", args...)
|
||||
launchArgs := clineLaunchArgs(model, args)
|
||||
cmd := exec.Command("cline", launchArgs...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func clineLaunchArgs(model string, extra []string) []string {
|
||||
return extra
|
||||
}
|
||||
|
||||
func (c *Cline) Paths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
p := filepath.Join(home, ".cline", "data", "globalState.json")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return []string{p}
|
||||
|
||||
var paths []string
|
||||
for _, p := range []string{
|
||||
clineProvidersPath(home),
|
||||
clineLegacyGlobalStatePath(home),
|
||||
} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return paths
|
||||
}
|
||||
|
||||
func (c *Cline) Edit(models []string) error {
|
||||
func (c *Cline) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -50,26 +65,113 @@ func (c *Cline) Edit(models []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".cline", "data", "globalState.json")
|
||||
providersPath := clineProvidersPath(home)
|
||||
legacyPath := clineLegacyGlobalStatePath(home)
|
||||
|
||||
providersConfig, err := readClineConfig(providersPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
legacyConfig, err := readClineConfig(legacyPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeClineProvidersConfig(providersPath, providersConfig, models[0].Name); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClineLegacyGlobalState(legacyPath, legacyConfig, models[0].Name)
|
||||
}
|
||||
|
||||
func clineProvidersPath(home string) string {
|
||||
return filepath.Join(home, ".cline", "data", "settings", "providers.json")
|
||||
}
|
||||
|
||||
func clineLegacyGlobalStatePath(home string) string {
|
||||
return filepath.Join(home, ".cline", "data", "globalState.json")
|
||||
}
|
||||
|
||||
func clineOllamaRootURL() string {
|
||||
return strings.TrimRight(envconfig.ConnectableHost().String(), "/")
|
||||
}
|
||||
|
||||
func clineProviderBaseURL() string {
|
||||
return clineOllamaRootURL() + "/v1"
|
||||
}
|
||||
|
||||
func readClineConfig(configPath string) (map[string]any, error) {
|
||||
config := make(map[string]any)
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config: %w, at: %s", err, configPath)
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
func writeClineProvidersConfig(configPath string, config map[string]any, model string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := make(map[string]any)
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return fmt.Errorf("failed to parse config: %w, at: %s", err, configPath)
|
||||
}
|
||||
providers, _ := config["providers"].(map[string]any)
|
||||
if providers == nil {
|
||||
providers = make(map[string]any)
|
||||
}
|
||||
|
||||
// Set Ollama as the provider for both act and plan modes
|
||||
baseURL := envconfig.Host().String()
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
if provider == nil {
|
||||
provider = make(map[string]any)
|
||||
}
|
||||
settings, _ := provider["settings"].(map[string]any)
|
||||
if settings == nil {
|
||||
settings = make(map[string]any)
|
||||
}
|
||||
|
||||
baseURL := clineProviderBaseURL()
|
||||
previousModel, _ := settings["model"].(string)
|
||||
previousBaseURL, _ := settings["baseUrl"].(string)
|
||||
previousTokenSource, _ := provider["tokenSource"].(string)
|
||||
|
||||
settings["provider"] = clineLaunchProvider
|
||||
settings["model"] = model
|
||||
settings["baseUrl"] = baseURL
|
||||
delete(settings, "apiKey")
|
||||
provider["settings"] = settings
|
||||
|
||||
if previousModel != model || previousBaseURL != baseURL || previousTokenSource != "manual" {
|
||||
provider["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
} else if _, ok := provider["updatedAt"].(string); !ok {
|
||||
provider["updatedAt"] = time.Now().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
provider["tokenSource"] = "manual"
|
||||
providers[clineLaunchProvider] = provider
|
||||
|
||||
config["version"] = float64(1)
|
||||
config["lastUsedProvider"] = clineLaunchProvider
|
||||
config["providers"] = providers
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, data, "cline")
|
||||
}
|
||||
|
||||
func writeClineLegacyGlobalState(configPath string, config map[string]any, model string) error {
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseURL := clineOllamaRootURL()
|
||||
config["ollamaBaseUrl"] = baseURL
|
||||
config["actModeApiProvider"] = "ollama"
|
||||
config["actModeOllamaModelId"] = models[0]
|
||||
config["actModeApiProvider"] = clineLaunchProvider
|
||||
config["actModeOllamaModelId"] = model
|
||||
config["actModeOllamaBaseUrl"] = baseURL
|
||||
config["planModeApiProvider"] = "ollama"
|
||||
config["planModeOllamaModelId"] = models[0]
|
||||
config["planModeApiProvider"] = clineLaunchProvider
|
||||
config["planModeOllamaModelId"] = model
|
||||
config["planModeOllamaBaseUrl"] = baseURL
|
||||
|
||||
config["welcomeViewCompleted"] = true
|
||||
@@ -78,7 +180,7 @@ func (c *Cline) Edit(models []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, data)
|
||||
return fileutil.WriteWithBackup(configPath, data, "cline")
|
||||
}
|
||||
|
||||
func (c *Cline) Models() []string {
|
||||
@@ -87,12 +189,18 @@ func (c *Cline) Models() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, err := fileutil.ReadJSON(filepath.Join(home, ".cline", "data", "globalState.json"))
|
||||
if model := clineProviderModel(home); model != "" {
|
||||
return []string{model}
|
||||
}
|
||||
|
||||
config, err := fileutil.ReadJSON(clineLegacyGlobalStatePath(home))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config["actModeApiProvider"] != "ollama" {
|
||||
switch config["actModeApiProvider"] {
|
||||
case "ollama":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -102,3 +210,18 @@ func (c *Cline) Models() []string {
|
||||
}
|
||||
return []string{modelID}
|
||||
}
|
||||
|
||||
func clineProviderModel(home string) string {
|
||||
config, err := fileutil.ReadJSON(clineProvidersPath(home))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if config["lastUsedProvider"] != clineLaunchProvider {
|
||||
return ""
|
||||
}
|
||||
providers, _ := config["providers"].(map[string]any)
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
settings, _ := provider["settings"].(map[string]any)
|
||||
model, _ := settings["model"].(string)
|
||||
return model
|
||||
}
|
||||
+215
-9
@@ -32,6 +32,7 @@ func TestClineEdit(t *testing.T) {
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".cline", "data")
|
||||
configPath := filepath.Join(configDir, "globalState.json")
|
||||
providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json")
|
||||
|
||||
readConfig := func() map[string]any {
|
||||
data, _ := os.ReadFile(configPath)
|
||||
@@ -40,34 +41,71 @@ func TestClineEdit(t *testing.T) {
|
||||
return config
|
||||
}
|
||||
|
||||
readProvidersConfig := func() map[string]any {
|
||||
data, _ := os.ReadFile(providersPath)
|
||||
var config map[string]any
|
||||
json.Unmarshal(data, &config)
|
||||
return config
|
||||
}
|
||||
|
||||
t.Run("creates config from scratch", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
config := readConfig()
|
||||
if config["actModeApiProvider"] != "ollama" {
|
||||
t.Errorf("actModeApiProvider = %v, want ollama", config["actModeApiProvider"])
|
||||
if config["actModeApiProvider"] != clineLaunchProvider {
|
||||
t.Errorf("actModeApiProvider = %v, want %s", config["actModeApiProvider"], clineLaunchProvider)
|
||||
}
|
||||
if config["actModeOllamaModelId"] != "kimi-k2.5:cloud" {
|
||||
t.Errorf("actModeOllamaModelId = %v, want kimi-k2.5:cloud", config["actModeOllamaModelId"])
|
||||
}
|
||||
if config["planModeApiProvider"] != "ollama" {
|
||||
t.Errorf("planModeApiProvider = %v, want ollama", config["planModeApiProvider"])
|
||||
if config["actModeOllamaBaseUrl"] != "http://127.0.0.1:11434" {
|
||||
t.Errorf("actModeOllamaBaseUrl = %v, want http://127.0.0.1:11434", config["actModeOllamaBaseUrl"])
|
||||
}
|
||||
if config["planModeApiProvider"] != clineLaunchProvider {
|
||||
t.Errorf("planModeApiProvider = %v, want %s", config["planModeApiProvider"], clineLaunchProvider)
|
||||
}
|
||||
if config["planModeOllamaModelId"] != "kimi-k2.5:cloud" {
|
||||
t.Errorf("planModeOllamaModelId = %v, want kimi-k2.5:cloud", config["planModeOllamaModelId"])
|
||||
}
|
||||
if config["planModeOllamaBaseUrl"] != "http://127.0.0.1:11434" {
|
||||
t.Errorf("planModeOllamaBaseUrl = %v, want http://127.0.0.1:11434", config["planModeOllamaBaseUrl"])
|
||||
}
|
||||
if config["ollamaBaseUrl"] != "http://127.0.0.1:11434" {
|
||||
t.Errorf("ollamaBaseUrl = %v, want http://127.0.0.1:11434", config["ollamaBaseUrl"])
|
||||
}
|
||||
if config["welcomeViewCompleted"] != true {
|
||||
t.Errorf("welcomeViewCompleted = %v, want true", config["welcomeViewCompleted"])
|
||||
}
|
||||
|
||||
providersConfig := readProvidersConfig()
|
||||
if providersConfig["lastUsedProvider"] != clineLaunchProvider {
|
||||
t.Errorf("lastUsedProvider = %v, want %s", providersConfig["lastUsedProvider"], clineLaunchProvider)
|
||||
}
|
||||
providers, _ := providersConfig["providers"].(map[string]any)
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
if provider["updatedAt"] == "" {
|
||||
t.Errorf("updatedAt = %v, want timestamp", provider["updatedAt"])
|
||||
}
|
||||
settings, _ := provider["settings"].(map[string]any)
|
||||
if settings["model"] != "kimi-k2.5:cloud" {
|
||||
t.Errorf("settings.model = %v, want kimi-k2.5:cloud", settings["model"])
|
||||
}
|
||||
if _, ok := settings["apiKey"]; ok {
|
||||
t.Errorf("settings.apiKey = %v, want omitted for local Ollama", settings["apiKey"])
|
||||
}
|
||||
if settings["baseUrl"] != "http://127.0.0.1:11434/v1" {
|
||||
t.Errorf("settings.baseUrl = %v, want http://127.0.0.1:11434/v1", settings["baseUrl"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves existing fields", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
|
||||
existing := map[string]any{
|
||||
"remoteRulesToggles": map[string]any{},
|
||||
@@ -77,7 +115,22 @@ func TestClineEdit(t *testing.T) {
|
||||
data, _ := json.Marshal(existing)
|
||||
os.WriteFile(configPath, data, 0o644)
|
||||
|
||||
if err := c.Edit([]string{"glm-5:cloud"}); err != nil {
|
||||
existingProviders := map[string]any{
|
||||
"customRoot": "keep-me-too",
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"updatedAt": "2026-05-29T16:56:46.111Z",
|
||||
"settings": map[string]any{
|
||||
"apiKey": "bad-migrated-key",
|
||||
"timeout": float64(30000),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ = json.Marshal(existingProviders)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
if err := c.Edit(testLaunchModels("glm-5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -88,15 +141,84 @@ func TestClineEdit(t *testing.T) {
|
||||
if config["actModeOllamaModelId"] != "glm-5:cloud" {
|
||||
t.Errorf("actModeOllamaModelId = %v, want glm-5:cloud", config["actModeOllamaModelId"])
|
||||
}
|
||||
|
||||
providersConfig := readProvidersConfig()
|
||||
if providersConfig["customRoot"] != "keep-me-too" {
|
||||
t.Errorf("customRoot was not preserved")
|
||||
}
|
||||
providers, _ := providersConfig["providers"].(map[string]any)
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
if provider["updatedAt"] == "2026-05-29T16:56:46.111Z" {
|
||||
t.Errorf("updatedAt = %v, want refreshed timestamp after provider change", provider["updatedAt"])
|
||||
}
|
||||
settings, _ := provider["settings"].(map[string]any)
|
||||
if settings["timeout"] != float64(30000) {
|
||||
t.Errorf("settings.timeout = %v, want 30000", settings["timeout"])
|
||||
}
|
||||
if _, ok := settings["apiKey"]; ok {
|
||||
t.Errorf("settings.apiKey = %v, want omitted for local Ollama", settings["apiKey"])
|
||||
}
|
||||
if settings["model"] != "glm-5:cloud" {
|
||||
t.Errorf("settings.model = %v, want glm-5:cloud", settings["model"])
|
||||
}
|
||||
if settings["baseUrl"] != "http://127.0.0.1:11434/v1" {
|
||||
t.Errorf("settings.baseUrl = %v, want http://127.0.0.1:11434/v1", settings["baseUrl"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validates both configs before writing providers config", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.WriteFile(configPath, []byte("{not json"), 0o644)
|
||||
|
||||
err := c.Edit(testLaunchModels("kimi-k2.5:cloud"))
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid legacy config error")
|
||||
}
|
||||
if _, statErr := os.Stat(providersPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("providers config should not be written when legacy config is invalid, stat err = %v", statErr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preserves updatedAt when provider settings are unchanged", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
|
||||
existingProviders := map[string]any{
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"updatedAt": "2026-05-29T16:56:46.111Z",
|
||||
"tokenSource": "manual",
|
||||
"settings": map[string]any{
|
||||
"provider": clineLaunchProvider,
|
||||
"model": "kimi-k2.5:cloud",
|
||||
"baseUrl": "http://127.0.0.1:11434/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(existingProviders)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
if err := c.Edit(testLaunchModels("kimi-k2.5:cloud")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
providersConfig := readProvidersConfig()
|
||||
providers, _ := providersConfig["providers"].(map[string]any)
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
if provider["updatedAt"] != "2026-05-29T16:56:46.111Z" {
|
||||
t.Errorf("updatedAt = %v, want preserved timestamp", provider["updatedAt"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("updates model on re-edit", func(t *testing.T) {
|
||||
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 +246,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)
|
||||
}
|
||||
|
||||
@@ -142,6 +264,7 @@ func TestClineModels(t *testing.T) {
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".cline", "data")
|
||||
configPath := filepath.Join(configDir, "globalState.json")
|
||||
providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json")
|
||||
|
||||
t.Run("returns nil when no config", func(t *testing.T) {
|
||||
if models := c.Models(); models != nil {
|
||||
@@ -177,6 +300,55 @@ func TestClineModels(t *testing.T) {
|
||||
t.Errorf("Models() = %v, want [kimi-k2.5:cloud]", models)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefers CLI provider config", func(t *testing.T) {
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
config := map[string]any{
|
||||
"lastUsedProvider": clineLaunchProvider,
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"settings": map[string]any{
|
||||
"model": "glm-5:cloud",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(config)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
models := c.Models()
|
||||
if len(models) != 1 || models[0] != "glm-5:cloud" {
|
||||
t.Errorf("Models() = %v, want [glm-5:cloud]", models)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores stale CLI provider config when ollama is not active", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
legacyConfig := map[string]any{
|
||||
"actModeApiProvider": "anthropic",
|
||||
"actModeOllamaModelId": "legacy-ollama-model",
|
||||
}
|
||||
data, _ := json.Marshal(legacyConfig)
|
||||
os.WriteFile(configPath, data, 0o644)
|
||||
providerConfig := map[string]any{
|
||||
"lastUsedProvider": "openai",
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"settings": map[string]any{
|
||||
"model": "stale-ollama-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ = json.Marshal(providerConfig)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
if models := c.Models(); models != nil {
|
||||
t.Errorf("Models() = %v, want nil", models)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClinePaths(t *testing.T) {
|
||||
@@ -201,4 +373,38 @@ func TestClinePaths(t *testing.T) {
|
||||
t.Errorf("Paths() = %v, want [%s]", paths, configPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns both paths when both configs exist", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
legacyPath := clineLegacyGlobalStatePath(tmpDir)
|
||||
providersPath := clineProvidersPath(tmpDir)
|
||||
os.MkdirAll(filepath.Dir(legacyPath), 0o755)
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
os.WriteFile(legacyPath, []byte("{}"), 0o644)
|
||||
os.WriteFile(providersPath, []byte("{}"), 0o644)
|
||||
|
||||
paths := c.Paths()
|
||||
want := []string{providersPath, legacyPath}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("Paths() = %v, want %v", paths, want)
|
||||
}
|
||||
for i := range want {
|
||||
if paths[i] != want[i] {
|
||||
t.Fatalf("Paths() = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClineLaunchArgs(t *testing.T) {
|
||||
got := clineLaunchArgs("kimi-k2.5:cloud", []string{"--json", "hello"})
|
||||
want := []string{"--json", "hello"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("args length = %d, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("args[%d] = %q, want %q; got %v", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+505
-57
@@ -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 {
|
||||
args := []string{"--profile", codexProfileName}
|
||||
codexRootProfileKey = "profile"
|
||||
codexRootModelKey = "model"
|
||||
codexRootModelProviderKey = "model_provider"
|
||||
codexRootModelCatalogJSONKey = "model_catalog_json"
|
||||
)
|
||||
|
||||
func (c *Codex) args(model, modelCatalogPath string, extra []string) []string {
|
||||
args := []string{}
|
||||
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,506 @@ 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 Codex root config 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 writeCodexConfig(configPath, modelName, catalogPath)
|
||||
}
|
||||
|
||||
// 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/"
|
||||
|
||||
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: fmt.Sprintf("[model_providers.%s]", codexProfileName),
|
||||
lines: []string{
|
||||
`name = "Ollama"`,
|
||||
fmt.Sprintf("base_url = %q", baseURL),
|
||||
},
|
||||
},
|
||||
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")
|
||||
}
|
||||
|
||||
// writeCodexConfig ensures ~/.codex/config.toml uses Ollama as the root model
|
||||
// provider without relying on Codex's legacy profile config.
|
||||
func writeCodexConfig(configPath, model, modelCatalogPath string) error {
|
||||
baseURL := codexBaseURL()
|
||||
|
||||
content, readErr := os.ReadFile(configPath)
|
||||
text := ""
|
||||
if readErr == nil {
|
||||
text = string(content)
|
||||
} else if !os.IsNotExist(readErr) {
|
||||
return readErr
|
||||
}
|
||||
if _, err := codexParseConfig(text); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range sections {
|
||||
block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n"
|
||||
text = codexRemoveRootValue(text, codexRootProfileKey)
|
||||
text = codexRemoveSection(text, codexProfileHeaderFor(codexProfileName))
|
||||
if strings.TrimSpace(model) != "" {
|
||||
text = codexSetRootStringValue(text, codexRootModelKey, model)
|
||||
}
|
||||
text = codexSetRootStringValue(text, codexRootModelProviderKey, codexProfileName)
|
||||
if strings.TrimSpace(modelCatalogPath) != "" {
|
||||
text = codexSetRootStringValue(text, codexRootModelCatalogJSONKey, modelCatalogPath)
|
||||
}
|
||||
text = codexUpsertSection(text, codexProviderHeaderFor(codexProfileName), []string{
|
||||
fmt.Sprintf("name = %q", codexProviderName),
|
||||
fmt.Sprintf("base_url = %q", baseURL),
|
||||
`wire_api = "responses"`,
|
||||
})
|
||||
|
||||
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
|
||||
parsed, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := codexValidateConfigTextForOllama(parsed, model, modelCatalogPath, baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, []byte(text), "")
|
||||
}
|
||||
|
||||
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 codexValidateConfigTextForOllama(config codexParsedConfig, model, modelCatalogPath, baseURL string) error {
|
||||
if got, ok := config.RootStringOK(codexRootProfileKey); ok {
|
||||
return fmt.Errorf("generated Codex config still contains legacy profile = %q", got)
|
||||
}
|
||||
if config.Exists("profiles", codexProfileName) {
|
||||
return fmt.Errorf("generated Codex config still contains legacy profiles.%s table", codexProfileName)
|
||||
}
|
||||
for _, check := range []struct {
|
||||
path []string
|
||||
want string
|
||||
}{
|
||||
{[]string{codexRootModelProviderKey}, codexProfileName},
|
||||
{[]string{"model_providers", codexProfileName, "name"}, codexProviderName},
|
||||
{[]string{"model_providers", codexProfileName, "base_url"}, baseURL},
|
||||
{[]string{"model_providers", codexProfileName, "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 model != "" {
|
||||
if got := config.RootString(codexRootModelKey); got != model {
|
||||
return fmt.Errorf("generated Codex config missing model = %q", model)
|
||||
}
|
||||
}
|
||||
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:]
|
||||
}
|
||||
}
|
||||
|
||||
return os.WriteFile(configPath, []byte(text), 0o644)
|
||||
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) Exists(path ...string) bool {
|
||||
if len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
var current any = c.values
|
||||
for _, part := range path {
|
||||
table, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
current, ok = table[part]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c codexParsedConfig) RootString(key string) string {
|
||||
value, _ := c.RootStringOK(key)
|
||||
return value
|
||||
}
|
||||
|
||||
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 {
|
||||
model.Name = modelName
|
||||
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
+455
-47
@@ -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{"-c", catalogArg, "-m", "llama3.2"}},
|
||||
{"empty model", "", nil, []string{"-c", catalogArg}},
|
||||
{"with profile flag", "qwen3.5", []string{"-p", "myprofile"}, []string{"-c", catalogArg, "-m", "qwen3.5", "-p", "myprofile"}},
|
||||
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, []string{"-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)
|
||||
}
|
||||
@@ -33,12 +41,13 @@ func TestCodexArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCodexProfile(t *testing.T) {
|
||||
func TestWriteCodexConfig(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 := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -48,36 +57,38 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
for _, want := range []string{
|
||||
`model = "llama3.2"`,
|
||||
`model_provider = "ollama-launch"`,
|
||||
fmt.Sprintf("model_catalog_json = %q", catalogPath),
|
||||
"[model_providers.ollama-launch]",
|
||||
`name = "Ollama"`,
|
||||
`base_url = "http://127.0.0.1:11434/v1/"`,
|
||||
`wire_api = "responses"`,
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(content, "openai_base_url") {
|
||||
t.Error("missing openai_base_url key")
|
||||
if got, ok := codexRootStringValueOK(content, "profile"); ok {
|
||||
t.Fatalf("legacy root profile should not be generated, got %q in:\n%s", got, content)
|
||||
}
|
||||
if !strings.Contains(content, "/v1/") {
|
||||
t.Error("missing /v1/ suffix in base URL")
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, `forced_login_method = "api"`) {
|
||||
t.Error("missing forced_login_method key")
|
||||
}
|
||||
if !strings.Contains(content, `model_provider = "ollama-launch"`) {
|
||||
t.Error("missing model_provider key")
|
||||
}
|
||||
if !strings.Contains(content, "[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) {
|
||||
t.Run("appends provider to existing file without provider", 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 := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -87,18 +98,22 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
if !strings.Contains(content, "[some_other_section]") {
|
||||
t.Error("existing section was removed")
|
||||
}
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "[model_providers.ollama-launch]") {
|
||||
t.Error("missing [model_providers.ollama-launch] header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaces existing profile section", func(t *testing.T) {
|
||||
t.Run("removes existing profile section and replaces provider 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 := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -108,21 +123,167 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
if strings.Contains(content, "old:1234") {
|
||||
t.Error("old URL was not replaced")
|
||||
}
|
||||
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [profiles.ollama-launch] section, got %d", strings.Count(content, "[profiles.ollama-launch]"))
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should be removed, got:\n%s", content)
|
||||
}
|
||||
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 profile while preserving following sections", func(t *testing.T) {
|
||||
t.Run("removes 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 := writeCodexConfig(configPath, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Contains(content, `profiles."ollama-launch"`) || strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("quoted profile table should be removed, got:\n%s", content)
|
||||
}
|
||||
if strings.Contains(content, "old:1234") {
|
||||
t.Fatalf("old URL was not replaced, got:\n%s", content)
|
||||
}
|
||||
if got, ok := codexRootStringValueOK(content, "profile"); ok {
|
||||
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
|
||||
}
|
||||
if got := codexRootStringValue(content, "model_provider"); got != codexProfileName {
|
||||
t.Fatalf("root 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 := writeCodexConfig(configPath, "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
||||
t.Fatalf("writeCodexConfig 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 := writeCodexConfig(configPath, "", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid Codex config TOML") {
|
||||
t.Fatalf("writeCodexConfig 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 := writeCodexConfig(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 and removes legacy profile", 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 := writeCodexConfig(configPath, "llama3.2", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
for key, want := range map[string]string{
|
||||
"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 got, ok := codexRootStringValueOK(content, "profile"); ok {
|
||||
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
|
||||
}
|
||||
if strings.Contains(content, `"profile"`) || strings.Contains(content, `"model_provider"`) {
|
||||
t.Fatalf("quoted root keys should be removed or 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("removes 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 := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -132,6 +293,9 @@ func TestWriteCodexProfile(t *testing.T) {
|
||||
if strings.Contains(content, "old:1234") {
|
||||
t.Error("old URL was not replaced")
|
||||
}
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should be removed, got:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "[another_section]") {
|
||||
t.Error("following section was removed")
|
||||
}
|
||||
@@ -143,18 +307,19 @@ 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 := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
// Should not have double blank lines from missing trailing newline
|
||||
if strings.Contains(content, "\n\n\n") {
|
||||
@@ -166,8 +331,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 := writeCodexConfig(configPath, "llama3.2", catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -178,6 +344,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 := writeCodexConfig(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 +371,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)
|
||||
}
|
||||
|
||||
@@ -196,11 +382,60 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "openai_base_url") {
|
||||
t.Error("missing openai_base_url key")
|
||||
if got := codexRootStringValue(content, "model"); got != "llama3.2" {
|
||||
t.Fatalf("root model = %q, want llama3.2 in:\n%s", got, content)
|
||||
}
|
||||
if got := codexRootStringValue(content, "model_provider"); got != codexProfileName {
|
||||
t.Fatalf("root model_provider = %q, want %q in:\n%s", got, codexProfileName, content)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
|
||||
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
|
||||
}
|
||||
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
data, err = os.ReadFile(catalogPath)
|
||||
if err != nil {
|
||||
t.Fatalf("model.json not created: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `"slug": "llama3.2"`) {
|
||||
t.Error("missing model catalog entry for selected model")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("writes requested local alias as catalog slug", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
models := []LaunchModel{
|
||||
{Name: "gemma4:latest", ContextLength: 65_536, Details: api.ModelDetails{Format: "gguf"}},
|
||||
}
|
||||
if err := ensureCodexConfig("gemma4", models); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
data, err := os.ReadFile(catalogPath)
|
||||
if err != nil {
|
||||
t.Fatalf("model.json not created: %v", err)
|
||||
}
|
||||
|
||||
var catalog struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &catalog); err != nil {
|
||||
t.Fatalf("model catalog should be valid JSON: %v", err)
|
||||
}
|
||||
if len(catalog.Models) != 1 {
|
||||
t.Fatalf("catalog model count = %d, want 1", len(catalog.Models))
|
||||
}
|
||||
if got := catalog.Models[0]["slug"]; got != "gemma4" {
|
||||
t.Fatalf("catalog slug = %v, want gemma4", got)
|
||||
}
|
||||
if got := catalog.Models[0]["context_window"]; got != float64(65_536) {
|
||||
t.Fatalf("context_window = %v, want 65536", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -208,10 +443,10 @@ 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)
|
||||
}
|
||||
if err := ensureCodexConfig(); err != nil {
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -219,11 +454,184 @@ func TestEnsureCodexConfig(t *testing.T) {
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [profiles.ollama-launch] section after two calls, got %d", strings.Count(content, "[profiles.ollama-launch]"))
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [model_providers.ollama-launch] section after two calls, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+102
-14
@@ -61,6 +61,9 @@ func TestLaunchCmd(t *testing.T) {
|
||||
if !strings.Contains(cmd.Long, "hermes") {
|
||||
t.Error("Long description should mention hermes")
|
||||
}
|
||||
if !strings.Contains(cmd.Long, "kimi") {
|
||||
t.Error("Long description should mention kimi")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flags exist", func(t *testing.T) {
|
||||
@@ -70,6 +73,9 @@ func TestLaunchCmd(t *testing.T) {
|
||||
if cmd.Flags().Lookup("config") == nil {
|
||||
t.Error("--config flag should exist")
|
||||
}
|
||||
if cmd.Flags().Lookup("restore") == nil {
|
||||
t.Error("--restore flag should exist")
|
||||
}
|
||||
if cmd.Flags().Lookup("yes") == nil {
|
||||
t.Error("--yes flag should exist")
|
||||
}
|
||||
@@ -204,6 +210,52 @@ func TestLaunchCmdTUICallback(t *testing.T) {
|
||||
t.Error("TUI callback should NOT be called when flags or extra args are provided without an integration")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("--restore flag without integration returns error", func(t *testing.T) {
|
||||
tuiCalled := false
|
||||
mockTUI := func(cmd *cobra.Command) {
|
||||
tuiCalled = true
|
||||
}
|
||||
|
||||
cmd := LaunchCmd(mockCheck, mockTUI)
|
||||
cmd.SetArgs([]string{"--restore"})
|
||||
err := cmd.Execute()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected --restore without an integration to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "require an integration name") {
|
||||
t.Fatalf("expected integration-name guidance, got %v", err)
|
||||
}
|
||||
if tuiCalled {
|
||||
t.Error("TUI callback should NOT be called when --restore is provided without an integration")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLaunchCmdClaudeDesktopLaunchReturnsUnsupported(t *testing.T) {
|
||||
for _, name := range []string{"claude-desktop", "claude-app"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error {
|
||||
t.Fatal("heartbeat check should not run before Claude Desktop unsupported error")
|
||||
return nil
|
||||
}, func(cmd *cobra.Command) {
|
||||
t.Fatal("TUI callback should not run for direct integration launch")
|
||||
})
|
||||
cmd.SetArgs([]string{name})
|
||||
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected Claude Desktop launch command to fail")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "Claude Desktop is no longer supported") {
|
||||
t.Fatalf("expected unsupported guidance, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "ollama launch claude-desktop --restore") {
|
||||
t.Fatalf("expected restore guidance, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchCmdNilHeartbeat(t *testing.T) {
|
||||
@@ -270,6 +322,8 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) {
|
||||
switch r.URL.Path {
|
||||
case "/api/status":
|
||||
fmt.Fprintf(w, `{"cloud":{"disabled":true,"source":"config"}}`)
|
||||
case "/api/experimental/model-recommendations":
|
||||
fmt.Fprint(w, `{"recommendations":[]}`)
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"llama3.2"}]}`)
|
||||
case "/api/show":
|
||||
@@ -290,7 +344,7 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) {
|
||||
|
||||
var selectorCalls int
|
||||
var gotCurrent string
|
||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
|
||||
selectorCalls++
|
||||
gotCurrent = current
|
||||
return "llama3.2", nil
|
||||
@@ -326,6 +380,41 @@ func TestLaunchCmdModelFlagClearsDisabledCloudOverride(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchCmdAutodiscoveryDefaultLaunchDoesNotForceConfigure(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withInteractiveSession(t, true)
|
||||
withLauncherHooks(t)
|
||||
|
||||
runner := &launcherManagedAutodiscoveryRunner{
|
||||
autodiscoveryConfigured: true,
|
||||
}
|
||||
restore := OverrideIntegration("stubauto", runner)
|
||||
defer restore()
|
||||
|
||||
if err := config.SaveIntegration("stubauto", []string{"Ollama Cloud"}); err != nil {
|
||||
t.Fatalf("failed to save managed integration config: %v", err)
|
||||
}
|
||||
if err := config.MarkIntegrationOnboarded("stubauto"); err != nil {
|
||||
t.Fatalf("failed to mark integration onboarded: %v", err)
|
||||
}
|
||||
|
||||
cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {
|
||||
t.Fatal("TUI callback should not run for direct integration launch")
|
||||
})
|
||||
cmd.SetArgs([]string{"stubauto"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("launch command failed: %v", err)
|
||||
}
|
||||
|
||||
if runner.autodiscoveryConfigures != 0 {
|
||||
t.Fatalf("expected default autodiscovery launch to reuse existing config, got %d configures", runner.autodiscoveryConfigures)
|
||||
}
|
||||
if runner.ranModel != "Ollama Cloud" {
|
||||
t.Fatalf("expected launch to run autodiscovery label, got %q", runner.ranModel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchCmdYes_AutoConfirmsLaunchPromptPath(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
@@ -415,7 +504,7 @@ func TestLaunchCmdHeadlessWithYes_AutoPullsMissingLocalModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchCmdHeadlessWithoutYes_ReturnsActionableConfirmError(t *testing.T) {
|
||||
func TestLaunchCmdHeadlessWithoutYes_AllowsConfiguredLaunch(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setLaunchTestHome(t, tmpDir)
|
||||
withLauncherHooks(t)
|
||||
@@ -447,17 +536,14 @@ func TestLaunchCmdHeadlessWithoutYes_ReturnsActionableConfirmError(t *testing.T)
|
||||
cmd := LaunchCmd(func(cmd *cobra.Command, args []string) error { return nil }, func(cmd *cobra.Command) {})
|
||||
cmd.SetArgs([]string{"stubeditor", "--model", "llama3.2"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected launch command to fail without --yes in headless mode")
|
||||
if err != nil {
|
||||
t.Fatalf("expected launch command to succeed without --yes when an explicit model is provided, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "re-run with --yes") {
|
||||
t.Fatalf("expected actionable --yes guidance, got %v", err)
|
||||
if diff := compareStringSlices(stub.edited, [][]string{{"llama3.2"}}); diff != "" {
|
||||
t.Fatalf("unexpected editor writes (-want +got):\n%s", diff)
|
||||
}
|
||||
if len(stub.edited) != 0 {
|
||||
t.Fatalf("expected no editor writes when confirmation is blocked, got %v", stub.edited)
|
||||
}
|
||||
if stub.ranModel != "" {
|
||||
t.Fatalf("expected launch to abort before run, got %q", stub.ranModel)
|
||||
if stub.ranModel != "llama3.2" {
|
||||
t.Fatalf("expected launch to run configured model, got %q", stub.ranModel)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -471,6 +557,8 @@ func TestLaunchCmdIntegrationArgPromptsForModelWithSavedSelection(t *testing.T)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/model-recommendations":
|
||||
fmt.Fprint(w, `{"recommendations":[]}`)
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"llama3.2"},{"name":"qwen3:8b"}]}`)
|
||||
case "/api/show":
|
||||
@@ -490,7 +578,7 @@ func TestLaunchCmdIntegrationArgPromptsForModelWithSavedSelection(t *testing.T)
|
||||
defer func() { DefaultSingleSelector = oldSelector }()
|
||||
|
||||
var gotCurrent string
|
||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
|
||||
gotCurrent = current
|
||||
return "qwen3:8b", nil
|
||||
}
|
||||
@@ -544,7 +632,7 @@ func TestLaunchCmdHeadlessYes_IntegrationRequiresModelEvenWhenSaved(t *testing.T
|
||||
|
||||
oldSelector := DefaultSingleSelector
|
||||
defer func() { DefaultSingleSelector = oldSelector }()
|
||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
|
||||
t.Fatal("selector should not be called for headless --yes saved-model launch")
|
||||
return "", nil
|
||||
}
|
||||
@@ -581,7 +669,7 @@ func TestLaunchCmdHeadlessYes_IntegrationWithoutSavedModelReturnsError(t *testin
|
||||
|
||||
oldSelector := DefaultSingleSelector
|
||||
defer func() { DefaultSingleSelector = oldSelector }()
|
||||
DefaultSingleSelector = func(title string, items []ModelItem, current string) (string, error) {
|
||||
DefaultSingleSelector = func(title string, items []SelectionItem, current string) (string, error) {
|
||||
t.Fatal("selector should not be called for headless --yes without saved model")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
// Copilot implements Runner for GitHub Copilot CLI integration.
|
||||
type Copilot struct{}
|
||||
|
||||
func (c *Copilot) String() string { return "Copilot CLI" }
|
||||
|
||||
func (c *Copilot) args(model string, extra []string) []string {
|
||||
var args []string
|
||||
if model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
args = append(args, extra...)
|
||||
return args
|
||||
}
|
||||
|
||||
func (c *Copilot) findPath() (string, error) {
|
||||
if p, err := exec.LookPath("copilot"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
name := "copilot"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "copilot.exe"
|
||||
}
|
||||
fallback := filepath.Join(home, ".local", "bin", name)
|
||||
if _, err := os.Stat(fallback); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
cmd := exec.Command(copilotPath, c.args(model, args)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
cmd.Env = append(os.Environ(), c.envVars(model)...)
|
||||
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// envVars returns the environment variables that configure Copilot CLI
|
||||
// to use Ollama as its model provider.
|
||||
func (c *Copilot) envVars(model string) []string {
|
||||
env := []string{
|
||||
"COPILOT_PROVIDER_BASE_URL=" + envconfig.Host().String() + "/v1",
|
||||
"COPILOT_PROVIDER_API_KEY=",
|
||||
"COPILOT_PROVIDER_WIRE_API=responses",
|
||||
}
|
||||
|
||||
if model != "" {
|
||||
env = append(env, "COPILOT_MODEL="+model)
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCopilotIntegration(t *testing.T) {
|
||||
c := &Copilot{}
|
||||
|
||||
t.Run("String", func(t *testing.T) {
|
||||
if got := c.String(); got != "Copilot CLI" {
|
||||
t.Errorf("String() = %q, want %q", got, "Copilot CLI")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("implements Runner", func(t *testing.T) {
|
||||
var _ Runner = c
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopilotFindPath(t *testing.T) {
|
||||
c := &Copilot{}
|
||||
|
||||
t.Run("finds copilot in PATH", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
name := "copilot"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "copilot.exe"
|
||||
}
|
||||
fakeBin := filepath.Join(tmpDir, name)
|
||||
os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0o755)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
got, err := c.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fakeBin {
|
||||
t.Errorf("findPath() = %q, want %q", got, fakeBin)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error when not in PATH", func(t *testing.T) {
|
||||
t.Setenv("PATH", t.TempDir()) // empty dir, no copilot binary
|
||||
|
||||
_, err := c.findPath()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to ~/.local/bin/copilot", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", t.TempDir()) // empty dir, no copilot binary
|
||||
|
||||
name := "copilot"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "copilot.exe"
|
||||
}
|
||||
fallback := filepath.Join(tmpDir, ".local", "bin", name)
|
||||
os.MkdirAll(filepath.Dir(fallback), 0o755)
|
||||
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
|
||||
|
||||
got, err := c.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fallback {
|
||||
t.Errorf("findPath() = %q, want %q", got, fallback)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error when neither PATH nor fallback exists", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", t.TempDir()) // empty dir, no copilot binary
|
||||
|
||||
_, err := c.findPath()
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCopilotArgs(t *testing.T) {
|
||||
c := &Copilot{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"with model", "llama3.2", nil, []string{"--model", "llama3.2"}},
|
||||
{"empty model", "", nil, nil},
|
||||
{"with model and extra", "llama3.2", []string{"--verbose"}, []string{"--model", "llama3.2", "--verbose"}},
|
||||
{"empty model with help", "", []string{"--help"}, []string{"--help"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := c.args(tt.model, tt.args)
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopilotEnvVars(t *testing.T) {
|
||||
c := &Copilot{}
|
||||
|
||||
envMap := func(envs []string) map[string]string {
|
||||
m := make(map[string]string)
|
||||
for _, e := range envs {
|
||||
k, v, _ := strings.Cut(e, "=")
|
||||
m[k] = v
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
t.Run("sets required provider env vars with model", func(t *testing.T) {
|
||||
got := envMap(c.envVars("llama3.2"))
|
||||
if got["COPILOT_PROVIDER_BASE_URL"] == "" {
|
||||
t.Error("COPILOT_PROVIDER_BASE_URL should be set")
|
||||
}
|
||||
if !strings.HasSuffix(got["COPILOT_PROVIDER_BASE_URL"], "/v1") {
|
||||
t.Errorf("COPILOT_PROVIDER_BASE_URL = %q, want /v1 suffix", got["COPILOT_PROVIDER_BASE_URL"])
|
||||
}
|
||||
if _, ok := got["COPILOT_PROVIDER_API_KEY"]; !ok {
|
||||
t.Error("COPILOT_PROVIDER_API_KEY should be set (empty)")
|
||||
}
|
||||
if got["COPILOT_PROVIDER_WIRE_API"] != "responses" {
|
||||
t.Errorf("COPILOT_PROVIDER_WIRE_API = %q, want %q", got["COPILOT_PROVIDER_WIRE_API"], "responses")
|
||||
}
|
||||
if got["COPILOT_MODEL"] != "llama3.2" {
|
||||
t.Errorf("COPILOT_MODEL = %q, want %q", got["COPILOT_MODEL"], "llama3.2")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omits COPILOT_MODEL when model is empty", func(t *testing.T) {
|
||||
got := envMap(c.envVars(""))
|
||||
if _, ok := got["COPILOT_MODEL"]; ok {
|
||||
t.Errorf("COPILOT_MODEL should not be set for empty model, got %q", got["COPILOT_MODEL"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses custom OLLAMA_HOST", func(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://myhost:9999")
|
||||
got := envMap(c.envVars("test"))
|
||||
if !strings.Contains(got["COPILOT_PROVIDER_BASE_URL"], "myhost:9999") {
|
||||
t.Errorf("COPILOT_PROVIDER_BASE_URL = %q, want custom host", got["COPILOT_PROVIDER_BASE_URL"])
|
||||
}
|
||||
})
|
||||
}
|
||||
+10
-12
@@ -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
|
||||
}
|
||||
@@ -96,10 +96,10 @@ func (d *Droid) Edit(models []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(settingsPath, data)
|
||||
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,
|
||||
})
|
||||
|
||||
+37
-37
@@ -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 {
|
||||
@@ -1172,7 +1172,7 @@ func TestDroidEdit_BackupCreated(t *testing.T) {
|
||||
|
||||
settingsDir := filepath.Join(tmpDir, ".factory")
|
||||
settingsPath := filepath.Join(settingsDir, "settings.json")
|
||||
backupDir := filepath.Join(os.TempDir(), "ollama-backups")
|
||||
backupDir := fileutil.BackupDir()
|
||||
|
||||
os.MkdirAll(settingsDir, 0o755)
|
||||
|
||||
@@ -1181,12 +1181,12 @@ 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)
|
||||
}
|
||||
|
||||
// Find backup containing our unique marker
|
||||
backups, _ := filepath.Glob(filepath.Join(backupDir, "settings.json.*"))
|
||||
backups, _ := filepath.Glob(filepath.Join(backupDir, "droid", "settings.json.*"))
|
||||
foundBackup := false
|
||||
for _, backup := range backups {
|
||||
data, err := os.ReadFile(backup)
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+34
-317
@@ -4,18 +4,15 @@ import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
@@ -66,23 +63,13 @@ var hermesMessagingEnvGroups = [][]string{
|
||||
// switching UX after startup.
|
||||
type Hermes struct{}
|
||||
|
||||
type hermesConfigBackend struct {
|
||||
displayPath string
|
||||
read func() ([]byte, error)
|
||||
write func([]byte) error
|
||||
}
|
||||
|
||||
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.
|
||||
if hermesGOOS == "windows" {
|
||||
return h.runWindows(args)
|
||||
}
|
||||
|
||||
bin, err := h.findUnixBinary()
|
||||
bin, err := h.binary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -95,21 +82,21 @@ func (h *Hermes) Run(_ string, args []string) error {
|
||||
}
|
||||
|
||||
func (h *Hermes) Paths() []string {
|
||||
backend, err := h.configBackend()
|
||||
configPath, err := hermesConfigPath()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []string{backend.displayPath}
|
||||
return []string{configPath}
|
||||
}
|
||||
|
||||
func (h *Hermes) Configure(model string) error {
|
||||
backend, err := h.configBackend()
|
||||
configPath, err := hermesConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := map[string]any{}
|
||||
if data, err := backend.read(); err == nil {
|
||||
if data, err := os.ReadFile(configPath); err == nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return fmt.Errorf("parse hermes config: %w", err)
|
||||
}
|
||||
@@ -142,15 +129,18 @@ func (h *Hermes) Configure(model string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return backend.write(data)
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, data, "hermes")
|
||||
}
|
||||
|
||||
func (h *Hermes) CurrentModel() string {
|
||||
backend, err := h.configBackend()
|
||||
configPath, err := hermesConfigPath()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := backend.read()
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -188,14 +178,7 @@ func (h *Hermes) RefreshRuntimeAfterConfigure() error {
|
||||
}
|
||||
|
||||
func (h *Hermes) installed() bool {
|
||||
if hermesGOOS == "windows" {
|
||||
if _, err := hermesLookPath("hermes"); err == nil {
|
||||
return true
|
||||
}
|
||||
return h.wslHasHermes()
|
||||
}
|
||||
|
||||
_, err := h.findUnixBinary()
|
||||
_, err := h.binary()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -205,7 +188,7 @@ func (h *Hermes) ensureInstalled() error {
|
||||
}
|
||||
|
||||
if hermesGOOS == "windows" {
|
||||
return h.ensureInstalledWindows()
|
||||
return hermesWindowsHint()
|
||||
}
|
||||
|
||||
var missing []string
|
||||
@@ -239,42 +222,6 @@ func (h *Hermes) ensureInstalled() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hermes) ensureInstalledWindows() error {
|
||||
// Hermes upstream support is WSL-oriented, so Windows launch uses a hybrid
|
||||
// WSL handoff that stays on the same install path as upstream Hermes.
|
||||
if _, err := hermesLookPath("hermes"); err == nil {
|
||||
return nil
|
||||
}
|
||||
if !h.wslAvailable() {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
if h.wslHasHermes() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ok, err := ConfirmPromptWithOptions("Hermes runs through WSL2 on Windows. Install it in WSL now?", ConfirmOptions{
|
||||
YesLabel: "Use WSL",
|
||||
NoLabel: "Show manual steps",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Hermes in WSL...\n")
|
||||
if err := h.runWSL("bash", "-lc", hermesInstallScript); err != nil {
|
||||
return hermesWindowsHint(fmt.Errorf("failed to install hermes in WSL: %w", err))
|
||||
}
|
||||
if !h.wslHasHermes() {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes install finished but the WSL binary was not found"))
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sHermes installed successfully in WSL%s\n\n", ansiGreen, ansiReset)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hermes) listModels(defaultModel string) []string {
|
||||
client := hermesOllamaClient()
|
||||
resp, err := client.List(context.Background())
|
||||
@@ -306,11 +253,15 @@ func (h *Hermes) listModels(defaultModel string) []string {
|
||||
return models
|
||||
}
|
||||
|
||||
func (h *Hermes) findUnixBinary() (string, error) {
|
||||
func (h *Hermes) binary() (string, error) {
|
||||
if path, err := hermesLookPath("hermes"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
if hermesGOOS == "windows" {
|
||||
return "", hermesWindowsHint()
|
||||
}
|
||||
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -323,70 +274,6 @@ func (h *Hermes) findUnixBinary() (string, error) {
|
||||
return "", fmt.Errorf("hermes is not installed")
|
||||
}
|
||||
|
||||
func (h *Hermes) runWindows(args []string) error {
|
||||
if path, err := hermesLookPath("hermes"); err == nil {
|
||||
if err := h.runGatewaySetupPreflight(args, func() error {
|
||||
return hermesAttachedCommand(path, "gateway", "setup").Run()
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
return hermesAttachedCommand(path, args...).Run()
|
||||
}
|
||||
if !h.wslAvailable() {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
if err := h.runGatewaySetupPreflight(args, func() error {
|
||||
return h.runWSL("hermes", "gateway", "setup")
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.runWSL(append([]string{"hermes"}, args...)...); err != nil {
|
||||
return hermesWindowsHint(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hermes) runWSL(args ...string) error {
|
||||
if !h.wslAvailable() {
|
||||
return fmt.Errorf("wsl.exe is not available")
|
||||
}
|
||||
|
||||
return hermesAttachedCommand("wsl.exe", "bash", "-lc", shellQuoteArgs(args)).Run()
|
||||
}
|
||||
|
||||
func (h *Hermes) runWSLCombinedOutput(args ...string) ([]byte, error) {
|
||||
if !h.wslAvailable() {
|
||||
return nil, fmt.Errorf("wsl.exe is not available")
|
||||
}
|
||||
|
||||
return hermesCommand("wsl.exe", "bash", "-lc", shellQuoteArgs(args)).CombinedOutput()
|
||||
}
|
||||
|
||||
func (h *Hermes) wslAvailable() bool {
|
||||
_, err := hermesLookPath("wsl.exe")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (h *Hermes) wslHasHermes() bool {
|
||||
if !h.wslAvailable() {
|
||||
return false
|
||||
}
|
||||
cmd := hermesCommand("wsl.exe", "bash", "-lc", "command -v hermes >/dev/null 2>&1")
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
func (h *Hermes) configBackend() (*hermesConfigBackend, error) {
|
||||
if hermesGOOS == "windows" {
|
||||
if _, err := hermesLookPath("hermes"); err == nil {
|
||||
return hermesLocalConfigBackend()
|
||||
}
|
||||
if h.wslAvailable() {
|
||||
return h.wslConfigBackend()
|
||||
}
|
||||
}
|
||||
return hermesLocalConfigBackend()
|
||||
}
|
||||
|
||||
func hermesConfigPath() (string, error) {
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
@@ -395,110 +282,6 @@ func hermesConfigPath() (string, error) {
|
||||
return filepath.Join(home, ".hermes", "config.yaml"), nil
|
||||
}
|
||||
|
||||
func hermesLocalConfigBackend() (*hermesConfigBackend, error) {
|
||||
configPath, err := hermesConfigPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &hermesConfigBackend{
|
||||
displayPath: configPath,
|
||||
read: func() ([]byte, error) {
|
||||
return os.ReadFile(configPath)
|
||||
},
|
||||
write: func(data []byte) error {
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, data)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Hermes) wslConfigBackend() (*hermesConfigBackend, error) {
|
||||
home, err := h.wslHome()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configPath := pathpkg.Join(home, ".hermes", "config.yaml")
|
||||
return &hermesConfigBackend{
|
||||
displayPath: configPath,
|
||||
read: func() ([]byte, error) {
|
||||
return h.readWSLFile(configPath)
|
||||
},
|
||||
write: func(data []byte) error {
|
||||
return h.writeWSLConfig(configPath, data)
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *Hermes) wslHome() (string, error) {
|
||||
if !h.wslAvailable() {
|
||||
return "", fmt.Errorf("wsl.exe is not available")
|
||||
}
|
||||
cmd := hermesCommand("wsl.exe", "bash", "-lc", `printf %s "$HOME"`)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
home := strings.TrimSpace(string(out))
|
||||
if home == "" {
|
||||
return "", fmt.Errorf("could not resolve WSL home directory")
|
||||
}
|
||||
return home, nil
|
||||
}
|
||||
|
||||
func (h *Hermes) readWSLFile(path string) ([]byte, error) {
|
||||
pathArg := shellQuoteArgs([]string{path})
|
||||
cmd := hermesCommand("wsl.exe", "bash", "-lc", fmt.Sprintf("if [ -f %s ]; then cat %s; else exit 42; fi", pathArg, pathArg))
|
||||
out, err := cmd.Output()
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) && exitErr.ExitCode() == 42 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (h *Hermes) writeWSLConfig(path string, data []byte) error {
|
||||
if existing, err := h.readWSLFile(path); err == nil {
|
||||
if !bytes.Equal(existing, data) {
|
||||
if err := hermesBackupData(path, existing); err != nil {
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("read existing file: %w", err)
|
||||
}
|
||||
|
||||
dir := pathpkg.Dir(path)
|
||||
dirArg := shellQuoteArgs([]string{dir})
|
||||
pathArg := shellQuoteArgs([]string{path})
|
||||
script := fmt.Sprintf(
|
||||
"dir=%s; path=%s; mkdir -p \"$dir\" && tmp=$(mktemp \"$dir/.tmp-XXXXXX\") && cat > \"$tmp\" && mv \"$tmp\" \"$path\"",
|
||||
dirArg,
|
||||
pathArg,
|
||||
)
|
||||
cmd := hermesCommand("wsl.exe", "bash", "-lc", script)
|
||||
cmd.Stdin = bytes.NewReader(data)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||
return fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hermesBackupData(path string, data []byte) error {
|
||||
if err := os.MkdirAll(fileutil.BackupDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
backupPath := filepath.Join(fileutil.BackupDir(), fmt.Sprintf("%s.%d", filepath.Base(path), time.Now().Unix()))
|
||||
return os.WriteFile(backupPath, data, 0o644)
|
||||
}
|
||||
|
||||
func hermesBaseURL() string {
|
||||
return strings.TrimRight(hermesOllamaURL().String(), "/") + "/v1"
|
||||
}
|
||||
@@ -554,8 +337,11 @@ func (h *Hermes) messagingConfigured() bool {
|
||||
func (h *Hermes) gatewayEnvVars() (map[string]string, error) {
|
||||
envVars := make(map[string]string)
|
||||
|
||||
data, err := h.readGatewayEnvFile()
|
||||
switch {
|
||||
envFilePath, err := hermesEnvPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch data, err := os.ReadFile(envFilePath); {
|
||||
case err == nil:
|
||||
for key, value := range hermesParseEnvFile(data) {
|
||||
envVars[key] = value
|
||||
@@ -566,12 +352,10 @@ func (h *Hermes) gatewayEnvVars() (map[string]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if h.usesLocalRuntimeEnv() {
|
||||
for _, group := range hermesMessagingEnvGroups {
|
||||
for _, key := range group {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
envVars[key] = value
|
||||
}
|
||||
for _, group := range hermesMessagingEnvGroups {
|
||||
for _, key := range group {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
envVars[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -579,39 +363,6 @@ func (h *Hermes) gatewayEnvVars() (map[string]string, error) {
|
||||
return envVars, nil
|
||||
}
|
||||
|
||||
func (h *Hermes) readGatewayEnvFile() ([]byte, error) {
|
||||
if hermesGOOS == "windows" {
|
||||
if _, err := hermesLookPath("hermes"); err == nil {
|
||||
path, err := hermesEnvPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
if h.wslAvailable() {
|
||||
home, err := h.wslHome()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.readWSLFile(pathpkg.Join(home, ".hermes", ".env"))
|
||||
}
|
||||
}
|
||||
|
||||
path, err := hermesEnvPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return os.ReadFile(path)
|
||||
}
|
||||
|
||||
func (h *Hermes) usesLocalRuntimeEnv() bool {
|
||||
if hermesGOOS != "windows" {
|
||||
return true
|
||||
}
|
||||
_, err := hermesLookPath("hermes")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (h *Hermes) gatewayRunning() (bool, error) {
|
||||
status, err := h.gatewayStatusOutput()
|
||||
if err != nil {
|
||||
@@ -621,19 +372,7 @@ func (h *Hermes) gatewayRunning() (bool, error) {
|
||||
}
|
||||
|
||||
func (h *Hermes) gatewayStatusOutput() (string, error) {
|
||||
if hermesGOOS == "windows" {
|
||||
if path, err := hermesLookPath("hermes"); err == nil {
|
||||
out, err := hermesCommand(path, "gateway", "status").CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
if !h.wslAvailable() {
|
||||
return "", hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
out, err := h.runWSLCombinedOutput("hermes", "gateway", "status")
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
bin, err := h.findUnixBinary()
|
||||
bin, err := h.binary()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -642,20 +381,7 @@ func (h *Hermes) gatewayStatusOutput() (string, error) {
|
||||
}
|
||||
|
||||
func (h *Hermes) restartGateway() error {
|
||||
if hermesGOOS == "windows" {
|
||||
if path, err := hermesLookPath("hermes"); err == nil {
|
||||
return hermesAttachedCommand(path, "gateway", "restart").Run()
|
||||
}
|
||||
if !h.wslAvailable() {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
}
|
||||
if err := h.runWSL("hermes", "gateway", "restart"); err != nil {
|
||||
return hermesWindowsHint(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
bin, err := h.findUnixBinary()
|
||||
bin, err := h.binary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -938,14 +664,6 @@ func mergeHermesToolsets(current any) any {
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuoteArgs(args []string) string {
|
||||
quoted := make([]string, 0, len(args))
|
||||
for _, arg := range args {
|
||||
quoted = append(quoted, "'"+strings.ReplaceAll(arg, "'", `'\''`)+"'")
|
||||
}
|
||||
return strings.Join(quoted, " ")
|
||||
}
|
||||
|
||||
func hermesAttachedCommand(name string, args ...string) *exec.Cmd {
|
||||
cmd := hermesCommand(name, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
@@ -954,9 +672,8 @@ func hermesAttachedCommand(name string, args ...string) *exec.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func hermesWindowsHint(err error) error {
|
||||
if hermesGOOS != "windows" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w\n\nHermes runs on Windows through WSL2.\nQuick setup: wsl --install\nInstaller docs: https://hermes-agent.nousresearch.com/docs/getting-started/installation/", err)
|
||||
func hermesWindowsHint() error {
|
||||
return fmt.Errorf("Hermes on Windows requires WSL2. Install WSL with: wsl --install\n" +
|
||||
"Then run 'ollama launch hermes' from inside your WSL shell.\n" +
|
||||
"Docs: https://hermes-agent.nousresearch.com/docs/getting-started/installation/")
|
||||
}
|
||||
+26
-153
@@ -49,15 +49,6 @@ func withHermesUserHome(t *testing.T, dir string) {
|
||||
})
|
||||
}
|
||||
|
||||
func withHermesLookPath(t *testing.T, fn func(string) (string, error)) {
|
||||
t.Helper()
|
||||
old := hermesLookPath
|
||||
hermesLookPath = fn
|
||||
t.Cleanup(func() {
|
||||
hermesLookPath = old
|
||||
})
|
||||
}
|
||||
|
||||
func clearHermesMessagingEnvVars(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, group := range hermesMessagingEnvGroups {
|
||||
@@ -112,6 +103,8 @@ func TestHermesConfigurePreservesExistingConfigAndEnablesWeb(t *testing.T) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||
case "/api/experimental/model-recommendations":
|
||||
fmt.Fprint(w, `{"recommendations":[]}`)
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"},{"name":"llama3.3"}]}`)
|
||||
default:
|
||||
@@ -224,6 +217,8 @@ func TestHermesConfigureUpdatesMatchingCustomProviderWithoutDroppingFields(t *te
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||
case "/api/experimental/model-recommendations":
|
||||
fmt.Fprint(w, `{"recommendations":[]}`)
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"},{"name":"llama3.3"}]}`)
|
||||
default:
|
||||
@@ -300,6 +295,8 @@ func TestHermesConfigureUsesLaunchResolvedHostForModelDiscovery(t *testing.T) {
|
||||
switch r.URL.Path {
|
||||
case "/api/show":
|
||||
fmt.Fprint(w, `{"model_info":{"general.context_length":131072}}`)
|
||||
case "/api/experimental/model-recommendations":
|
||||
fmt.Fprint(w, `{"recommendations":[]}`)
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"},{"name":"llama3.3"}]}`)
|
||||
default:
|
||||
@@ -365,6 +362,8 @@ func TestHermesConfigureMigratesLegacyManagedAliases(t *testing.T) {
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/experimental/model-recommendations":
|
||||
fmt.Fprint(w, `{"recommendations":[]}`)
|
||||
case "/api/tags":
|
||||
fmt.Fprint(w, `{"models":[{"name":"gemma4"},{"name":"qwen3.5"}]}`)
|
||||
default:
|
||||
@@ -553,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)
|
||||
}
|
||||
|
||||
@@ -604,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)
|
||||
}
|
||||
|
||||
@@ -656,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)
|
||||
}
|
||||
|
||||
@@ -714,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)
|
||||
}
|
||||
|
||||
@@ -754,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)
|
||||
}
|
||||
|
||||
@@ -799,7 +798,7 @@ fi
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
err := h.Run("", nil)
|
||||
err := h.Run("", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected messaging setup failure")
|
||||
}
|
||||
@@ -896,64 +895,6 @@ fi
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesRefreshRuntimeAfterConfigure_WindowsWSLRestartsRunningGateway(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell test binaries to simulate WSL")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withHermesPlatform(t, "windows")
|
||||
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
wslPath := filepath.Join(tmpDir, "wsl.exe")
|
||||
wslScript := `#!/bin/sh
|
||||
printf '[%s]\n' "$*" >> "$HOME/wsl-invocations.log"
|
||||
exec /bin/sh -lc "$3"
|
||||
`
|
||||
if err := os.WriteFile(wslPath, []byte(wslScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hermesBin := filepath.Join(tmpDir, "hermes")
|
||||
hermesScript := `#!/bin/sh
|
||||
printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log"
|
||||
if [ "$1" = "gateway" ] && [ "$2" = "status" ]; then
|
||||
printf '✓ Gateway is running (PID: 321)\n'
|
||||
fi
|
||||
`
|
||||
if err := os.WriteFile(hermesBin, []byte(hermesScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
withHermesLookPath(t, func(file string) (string, error) {
|
||||
if file == "wsl.exe" {
|
||||
return wslPath, nil
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
})
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.RefreshRuntimeAfterConfigure(); err != nil {
|
||||
t.Fatalf("RefreshRuntimeAfterConfigure returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected WSL status then restart invocations, got %v", lines)
|
||||
}
|
||||
if lines[0] != "[gateway status]" {
|
||||
t.Fatalf("expected WSL gateway status first, got %q", lines[0])
|
||||
}
|
||||
if lines[1] != "[gateway restart]" {
|
||||
t.Fatalf("expected WSL gateway restart second, got %q", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesMessagingConfiguredRecognizesSupportedGatewayVars(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -1002,82 +943,7 @@ func TestHermesMessagingConfiguredRecognizesSupportedGatewayVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesRunWindowsWSL_UsesGatewaySetupPreflight(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell test binaries to simulate WSL")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withLauncherHooks(t)
|
||||
withInteractiveSession(t, true)
|
||||
withHermesPlatform(t, "windows")
|
||||
clearHermesMessagingEnvVars(t)
|
||||
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
|
||||
wslPath := filepath.Join(tmpDir, "wsl.exe")
|
||||
wslScript := `#!/bin/sh
|
||||
printf '[%s]\n' "$*" >> "$HOME/wsl-invocations.log"
|
||||
exec /bin/sh -lc "$3"
|
||||
`
|
||||
if err := os.WriteFile(wslPath, []byte(wslScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hermesBin := filepath.Join(tmpDir, "hermes")
|
||||
hermesScript := `#!/bin/sh
|
||||
printf '[%s]\n' "$*" >> "$HOME/hermes-invocations.log"
|
||||
if [ "$1" = "gateway" ] && [ "$2" = "setup" ]; then
|
||||
/bin/mkdir -p "$HOME/.hermes"
|
||||
printf 'TELEGRAM_BOT_TOKEN=configured\n' > "$HOME/.hermes/.env"
|
||||
fi
|
||||
`
|
||||
if err := os.WriteFile(hermesBin, []byte(hermesScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
withHermesLookPath(t, func(file string) (string, error) {
|
||||
if file == "wsl.exe" {
|
||||
return wslPath, nil
|
||||
}
|
||||
return "", os.ErrNotExist
|
||||
})
|
||||
|
||||
promptCount := 0
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
promptCount++
|
||||
if prompt != hermesGatewaySetupTitle {
|
||||
t.Fatalf("unexpected prompt %q", prompt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
if promptCount != 1 {
|
||||
t.Fatalf("expected one messaging prompt, got %d", promptCount)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "hermes-invocations.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("expected WSL hermes to run setup then launch, got %v", lines)
|
||||
}
|
||||
if lines[0] != "[gateway setup]" {
|
||||
t.Fatalf("expected WSL gateway setup first, got %q", lines[0])
|
||||
}
|
||||
if lines[1] != "[]" {
|
||||
t.Fatalf("expected WSL default hermes launch second, got %q", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesEnsureInstalledWindowsWithoutWSLGivesGuidance(t *testing.T) {
|
||||
func TestHermesEnsureInstalledWindowsShowsWSLGuidance(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withHermesPlatform(t, "windows")
|
||||
@@ -1086,10 +952,17 @@ func TestHermesEnsureInstalledWindowsWithoutWSLGivesGuidance(t *testing.T) {
|
||||
h := &Hermes{}
|
||||
err := h.ensureInstalled()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing WSL guidance error")
|
||||
t.Fatal("expected WSL guidance error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "wsl --install") {
|
||||
t.Fatalf("expected WSL guidance, got %v", err)
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "wsl --install") {
|
||||
t.Fatalf("expected install command in guidance, got %v", err)
|
||||
}
|
||||
if !strings.Contains(msg, "hermes-agent.nousresearch.com") {
|
||||
t.Fatalf("expected docs link in guidance, got %v", err)
|
||||
}
|
||||
if strings.Contains(msg, "hermes is not installed") {
|
||||
t.Fatalf("guidance should not lead with 'hermes is not installed', got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+407
-32
@@ -10,7 +10,9 @@ import (
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/ollama/ollama/api"
|
||||
@@ -23,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
|
||||
}
|
||||
@@ -32,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
|
||||
}
|
||||
@@ -53,9 +55,16 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
{"claude lowercase", "claude", true, "Claude Code"},
|
||||
{"claude uppercase", "CLAUDE", true, "Claude Code"},
|
||||
{"claude mixed case", "Claude", true, "Claude Code"},
|
||||
{"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"},
|
||||
{"pool", "pool", true, "Pool"},
|
||||
{"unknown integration", "unknown", false, ""},
|
||||
{"empty string", "", false, ""},
|
||||
}
|
||||
@@ -74,8 +83,7 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIntegrationRegistry(t *testing.T) {
|
||||
expectedIntegrations := []string{"claude", "codex", "droid", "opencode", "hermes"}
|
||||
|
||||
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]
|
||||
@@ -89,6 +97,15 @@ func TestIntegrationRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
|
||||
for _, info := range ListIntegrationInfos() {
|
||||
switch info.Name {
|
||||
case "cline", "vscode", "kimi":
|
||||
t.Fatalf("hidden integration %q should not appear in ListIntegrationInfos", info.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasLocalModel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -126,6 +143,23 @@ func TestLookupIntegration_UnknownIntegration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupIntegration_ClaudeDesktopResolvesForRestore(t *testing.T) {
|
||||
for _, name := range []string{"claude-desktop", "claude-app"} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
canonical, runner, err := LookupIntegration(name)
|
||||
if err != nil {
|
||||
t.Fatalf("expected Claude Desktop lookup to resolve, got: %v", err)
|
||||
}
|
||||
if canonical != "claude-desktop" {
|
||||
t.Fatalf("canonical name = %q, want claude-desktop", canonical)
|
||||
}
|
||||
if runner.String() != "Claude Desktop" {
|
||||
t.Fatalf("runner = %q, want Claude Desktop", runner.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsIntegrationInstalled_UnknownIntegrationReturnsFalse(t *testing.T) {
|
||||
stderr := captureStderr(t, func() {
|
||||
if IsIntegrationInstalled("unknown-integration") {
|
||||
@@ -172,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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -291,7 +325,7 @@ func TestParseArgs(t *testing.T) {
|
||||
func TestIsCloudModel(t *testing.T) {
|
||||
// isCloudModel now only uses Show API, so nil client always returns false
|
||||
t.Run("nil client returns false", func(t *testing.T) {
|
||||
models := []string{"glm-5.1:cloud", "kimi-k2.5:cloud", "local-model"}
|
||||
models := []string{"glm-5.1:cloud", "kimi-k2.6:cloud", "local-model"}
|
||||
for _, model := range models {
|
||||
if isCloudModel(context.Background(), nil, model) {
|
||||
t.Errorf("isCloudModel(%q) with nil client should return false", model)
|
||||
@@ -308,10 +342,18 @@ func names(items []ModelItem) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func recommendedNames(extra ...string) []string {
|
||||
out := make([]string, 0, len(recommendedModels)+len(extra))
|
||||
for _, item := range recommendedModels {
|
||||
out = append(out, item.Name)
|
||||
}
|
||||
return append(out, extra...)
|
||||
}
|
||||
|
||||
func TestBuildModelList_NoExistingModels(t *testing.T) {
|
||||
items, _, _, _ := buildModelList(nil, nil, "")
|
||||
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
|
||||
want := recommendedNames()
|
||||
if diff := cmp.Diff(want, names(items)); diff != "" {
|
||||
t.Errorf("with no existing models, items should be recommended in order (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -340,7 +382,7 @@ func TestBuildModelList_OnlyLocalModels_CloudRecsStillFirst(t *testing.T) {
|
||||
|
||||
// Cloud recs always come first among recommended, regardless of installed inventory.
|
||||
// Cloud disablement is handled upstream in loadSelectableModels via filterCloudItems.
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2", "qwen2.5"}
|
||||
want := recommendedNames("llama3.2", "qwen2.5")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("cloud recs pinned first even when no cloud models installed (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -356,13 +398,13 @@ func TestBuildModelList_BothCloudAndLocal_RegularSort(t *testing.T) {
|
||||
got := names(items)
|
||||
|
||||
// All recs pinned at top (cloud before local in mixed case), then non-recs
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
|
||||
want := recommendedNames("llama3.2")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("recs pinned at top, cloud recs first in mixed case (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
||||
func TestBuildModelList_PreCheckedNonRecommendedFirstInMore(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
@@ -371,8 +413,9 @@ func TestBuildModelList_PreCheckedFirst(t *testing.T) {
|
||||
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
||||
got := names(items)
|
||||
|
||||
if got[0] != "llama3.2" {
|
||||
t.Errorf("pre-checked model should be first, got %v", got)
|
||||
want := recommendedNames("llama3.2")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("recommended block should stay fixed while checked non-recommended models lead More (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,7 +470,7 @@ func TestBuildModelList_ExistingRecommendedMarked(t *testing.T) {
|
||||
if !strings.HasSuffix(item.Description, "(not downloaded)") {
|
||||
t.Errorf("non-installed recommended %q should have '(not downloaded)' suffix, got %q", item.Name, item.Description)
|
||||
}
|
||||
case "minimax-m2.7:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud":
|
||||
case "minimax-m2.7:cloud", "kimi-k2.6:cloud", "qwen3.5:cloud":
|
||||
if strings.HasSuffix(item.Description, "(not downloaded)") {
|
||||
t.Errorf("cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description)
|
||||
}
|
||||
@@ -435,6 +478,28 @@ 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",
|
||||
Details: api.ModelDetails{ContextLength: 202_752},
|
||||
},
|
||||
}
|
||||
existing := []modelInfo{{Name: "glm-5:cloud", Remote: true}}
|
||||
|
||||
items, _, _, _ := buildModelListWithRecommendations(existing, recommendations, nil, "")
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected one item, got %v", items)
|
||||
}
|
||||
item := items[0]
|
||||
if item.RequiredPlan != "pro" {
|
||||
t.Fatalf("RequiredPlan = %q, want pro", item.RequiredPlan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "gemma4", Remote: false},
|
||||
@@ -445,9 +510,9 @@ func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
|
||||
got := names(items)
|
||||
|
||||
// gemma4 and glm-5.1:cloud are installed so they sort normally;
|
||||
// kimi-k2.5:cloud, qwen3.5:cloud, and qwen3.5 are not installed so they go to the bottom
|
||||
// qwen3.5:cloud and qwen3.5 are not installed so they go to the bottom
|
||||
// All recs: cloud first in mixed case, then local, in rec order within each
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5"}
|
||||
want := recommendedNames()
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("all recs, cloud first in mixed case (-want +got):\n%s", diff)
|
||||
}
|
||||
@@ -456,23 +521,23 @@ func TestBuildModelList_ExistingCloudModelsNotPushedToBottom(t *testing.T) {
|
||||
func TestBuildModelList_HasRecommendedCloudModel_OnlyNonInstalledAtBottom(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "kimi-k2.5:cloud", Remote: true},
|
||||
{Name: "kimi-k2.6:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, nil, "")
|
||||
got := names(items)
|
||||
|
||||
// kimi-k2.5:cloud is installed so it sorts normally;
|
||||
// kimi-k2.6:cloud is installed so it sorts normally;
|
||||
// the rest of the recommendations are not installed so they go to the bottom
|
||||
// All recs pinned at top (cloud first in mixed case), then non-recs
|
||||
want := []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud", "gemma4", "qwen3.5", "llama3.2"}
|
||||
want := recommendedNames("llama3.2")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("recs pinned at top, cloud first in mixed case (-want +got):\n%s", diff)
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
isCloud := strings.HasSuffix(item.Name, ":cloud")
|
||||
isInstalled := slices.Contains([]string{"kimi-k2.5:cloud", "llama3.2"}, item.Name)
|
||||
isInstalled := slices.Contains([]string{"kimi-k2.6:cloud", "llama3.2"}, item.Name)
|
||||
if isInstalled || isCloud {
|
||||
if strings.HasSuffix(item.Description, "(not downloaded)") {
|
||||
t.Errorf("installed or cloud model %q should not have '(not downloaded)' suffix, got %q", item.Name, item.Description)
|
||||
@@ -539,8 +604,8 @@ func TestBuildModelList_ReturnsExistingAndCloudMaps(t *testing.T) {
|
||||
if !cloudModels["glm-5.1:cloud"] {
|
||||
t.Error("glm-5.1:cloud should be in cloudModels")
|
||||
}
|
||||
if !cloudModels["kimi-k2.5:cloud"] {
|
||||
t.Error("kimi-k2.5:cloud should be in cloudModels (recommended cloud)")
|
||||
if !cloudModels["kimi-k2.6:cloud"] {
|
||||
t.Error("kimi-k2.6:cloud should be in cloudModels (recommended cloud)")
|
||||
}
|
||||
if !cloudModels["qwen3.5:cloud"] {
|
||||
t.Error("qwen3.5:cloud should be in cloudModels (recommended cloud)")
|
||||
@@ -560,7 +625,7 @@ func TestBuildModelList_RecommendedFieldSet(t *testing.T) {
|
||||
|
||||
for _, item := range items {
|
||||
switch item.Name {
|
||||
case "gemma4", "qwen3.5", "glm-5.1:cloud", "kimi-k2.5:cloud", "qwen3.5:cloud":
|
||||
case "gemma4", "qwen3.5", "glm-5.1:cloud", "kimi-k2.6:cloud", "qwen3.5:cloud":
|
||||
if !item.Recommended {
|
||||
t.Errorf("%q should have Recommended=true", item.Name)
|
||||
}
|
||||
@@ -618,7 +683,7 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
|
||||
lastRecIdx := -1
|
||||
firstNonRecIdx := len(got)
|
||||
for i, name := range got {
|
||||
isRec := name == "gemma4" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5.1:cloud" || name == "kimi-k2.5:cloud" || name == "qwen3.5:cloud"
|
||||
isRec := name == "gemma4" || name == "qwen3.5" || name == "minimax-m2.7:cloud" || name == "glm-5.1:cloud" || name == "kimi-k2.6:cloud" || name == "qwen3.5:cloud"
|
||||
if isRec && i > lastRecIdx {
|
||||
lastRecIdx = i
|
||||
}
|
||||
@@ -631,17 +696,32 @@ func TestBuildModelList_RecsAboveNonRecs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_CheckedBeforeRecs(t *testing.T) {
|
||||
func TestBuildModelList_CheckedRecommendedDoesNotReshuffleRecommendedOrder(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "llama3.2:latest", Remote: false},
|
||||
{Name: "glm-5.1:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, []string{"llama3.2"}, "")
|
||||
items, _, _, _ := buildModelList(existing, []string{"qwen3.5:cloud", "glm-5.1:cloud"}, "")
|
||||
got := names(items)
|
||||
|
||||
if got[0] != "llama3.2" {
|
||||
t.Errorf("checked model should be first even before recs, got %v", got)
|
||||
want := recommendedNames("llama3.2")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("checked recommended models should not reshuffle the fixed recommended order (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_StaleSavedKimiK25DoesNotReshuffleRecommendedOrder(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
{Name: "kimi-k2.5:cloud", Remote: true},
|
||||
}
|
||||
|
||||
items, _, _, _ := buildModelList(existing, []string{"kimi-k2.5:cloud", "qwen3.5:cloud", "glm-5.1:cloud", "minimax-m2.7:cloud"}, "kimi-k2.5:cloud")
|
||||
got := names(items)
|
||||
|
||||
want := recommendedNames("kimi-k2.5:cloud")
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("stale saved kimi-k2.5 should stay in More without reshuffling the fixed recommended order (-want +got):\n%s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,7 +866,7 @@ func TestPrepareEditorIntegration_SavesOnlyAfterSuccessfulEdit(t *testing.T) {
|
||||
}
|
||||
|
||||
editor := &stubEditorRunner{editErr: errors.New("boom")}
|
||||
err := prepareEditorIntegration("droid", editor, 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)
|
||||
}
|
||||
@@ -1330,6 +1410,211 @@ func TestEnsureAuth_SkipsWhenNoCloudSelected(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAuth_EmptyWhoamiRequiresSignIn(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/status":
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, `{"error":"not found"}`)
|
||||
case "/api/me":
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprintf(w, `{}`)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
client := api.NewClient(u, srv.Client())
|
||||
|
||||
err := ensureAuth(context.Background(), client, map[string]bool{"cloud-model:cloud": true}, []string{"cloud-model:cloud"})
|
||||
if err == nil || !strings.Contains(err.Error(), "cloud-model:cloud requires sign in") {
|
||||
t.Fatalf("ensureAuth error = %v, want sign-in required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAccountStateToSelectionItems_BadgesOnlyWhenActionRequired(t *testing.T) {
|
||||
items := []ModelItem{
|
||||
{Name: "qwen3.5:cloud", Recommended: true},
|
||||
{Name: "kimi-k2.6:cloud", Recommended: true, RequiredPlan: "pro"},
|
||||
{Name: "llama3.2", RequiredPlan: "pro"},
|
||||
{Name: "glm-5:cloud"},
|
||||
{Name: "nemotron-3-super:cloud", Recommended: true, RequiredPlan: "free"},
|
||||
}
|
||||
|
||||
signedOut := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateSignedOut})
|
||||
if signedOut[0].AvailabilityBadge != "Sign in required" {
|
||||
t.Fatalf("account cloud badge = %q", signedOut[0].AvailabilityBadge)
|
||||
}
|
||||
if signedOut[1].AvailabilityBadge != "Sign in required" {
|
||||
t.Fatalf("subscription cloud signed-out badge = %q", signedOut[1].AvailabilityBadge)
|
||||
}
|
||||
if signedOut[4].AvailabilityBadge != "Sign in required" {
|
||||
t.Fatalf("free-plan cloud signed-out badge = %q", signedOut[4].AvailabilityBadge)
|
||||
}
|
||||
if signedOut[2].AvailabilityBadge != "" || signedOut[3].AvailabilityBadge != "" {
|
||||
t.Fatalf("unexpected badge for local or unmetadata item: %#v", signedOut)
|
||||
}
|
||||
|
||||
freeUser := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateSignedIn, Plan: "free"})
|
||||
if freeUser[0].AvailabilityBadge != "" {
|
||||
t.Fatalf("signed-in account model should not be badged, got %q", freeUser[0].AvailabilityBadge)
|
||||
}
|
||||
if freeUser[1].AvailabilityBadge != "Upgrade required" {
|
||||
t.Fatalf("subscription cloud free-plan badge = %q", freeUser[1].AvailabilityBadge)
|
||||
}
|
||||
if freeUser[4].AvailabilityBadge != "" {
|
||||
t.Fatalf("free required plan should be usable by free user, got %q", freeUser[4].AvailabilityBadge)
|
||||
}
|
||||
|
||||
proUser := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateSignedIn, Plan: "pro"})
|
||||
if proUser[1].AvailabilityBadge != "" {
|
||||
t.Fatalf("pro user should not see included badge, got %q", proUser[1].AvailabilityBadge)
|
||||
}
|
||||
|
||||
maxUser := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateSignedIn, Plan: "max"})
|
||||
if maxUser[1].AvailabilityBadge != "" {
|
||||
t.Fatalf("max user should not see upgrade badge, got %q", maxUser[1].AvailabilityBadge)
|
||||
}
|
||||
|
||||
unknown := ApplyAccountStateToSelectionItems(items, AccountState{Status: accountStateUnknown})
|
||||
for _, item := range unknown {
|
||||
if item.AvailabilityBadge != "" {
|
||||
t.Fatalf("unknown account state should not render badges: %#v", unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionItemsWithAccountState_SkipsBadgesWithoutBadgeableCloudItems(t *testing.T) {
|
||||
items := []ModelItem{
|
||||
{Name: "llama3.2"},
|
||||
{Name: "custom:cloud"},
|
||||
}
|
||||
state := &AccountState{Status: accountStateSignedOut}
|
||||
got := SelectionItemsWithAccountState(items, state)
|
||||
if len(got) != len(items) {
|
||||
t.Fatalf("got %d selection items, want %d", len(got), len(items))
|
||||
}
|
||||
for _, item := range got {
|
||||
if item.AvailabilityBadge != "" {
|
||||
t.Fatalf("unexpected badge without account state: %#v", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectionItemsWithAccountState_UsesPrefetchedStateForRecommendedCloudItems(t *testing.T) {
|
||||
state := &AccountState{Status: accountStateSignedOut}
|
||||
got := SelectionItemsWithAccountState([]ModelItem{{Name: "qwen3.5:cloud", Recommended: true}}, state)
|
||||
if got[0].AvailabilityBadge != "Sign in required" {
|
||||
t.Fatalf("badge = %q, want Sign in required", got[0].AvailabilityBadge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendedModelsDoNotIncludeRequiredPlanStubs(t *testing.T) {
|
||||
byName := make(map[string]ModelItem, len(recommendedModels))
|
||||
for _, item := range recommendedModels {
|
||||
byName[item.Name] = item
|
||||
}
|
||||
|
||||
if item := byName["kimi-k2.6:cloud"]; item.RequiredPlan != "" {
|
||||
t.Fatalf("kimi fallback required plan should not be stubbed: %#v", item)
|
||||
}
|
||||
if item := byName["minimax-m2.7:cloud"]; item.RequiredPlan != "" {
|
||||
t.Fatalf("minimax fallback required plan should not be stubbed: %#v", item)
|
||||
}
|
||||
if item := byName["qwen3.5:cloud"]; item.RequiredPlan != "" {
|
||||
t.Fatalf("qwen fallback required plan = %#v", item)
|
||||
}
|
||||
if item := byName["glm-5.1:cloud"]; item.RequiredPlan != "" {
|
||||
t.Fatalf("glm fallback required plan = %#v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchAccountState(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
body string
|
||||
wantStatus accountStateStatus
|
||||
wantPlan string
|
||||
}{
|
||||
{
|
||||
name: "signed in",
|
||||
statusCode: http.StatusOK,
|
||||
body: `{"name":"parth","plan":"pro"}`,
|
||||
wantStatus: accountStateSignedIn,
|
||||
wantPlan: "pro",
|
||||
},
|
||||
{
|
||||
name: "signed out",
|
||||
statusCode: http.StatusUnauthorized,
|
||||
body: `{"error":"unauthorized","signin_url":"https://example.com/signin"}`,
|
||||
wantStatus: accountStateSignedOut,
|
||||
},
|
||||
{
|
||||
name: "unreachable",
|
||||
statusCode: http.StatusInternalServerError,
|
||||
body: `{"error":"temporary failure"}`,
|
||||
wantStatus: accountStateUnknown,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/me" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(tt.statusCode)
|
||||
fmt.Fprint(w, tt.body)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, _ := url.Parse(srv.URL)
|
||||
got := launchAccountState(context.Background(), api.NewClient(u, srv.Client()))
|
||||
if got.Status != tt.wantStatus {
|
||||
t.Fatalf("Status = %v, want %v", got.Status, tt.wantStatus)
|
||||
}
|
||||
if got.Plan != tt.wantPlan {
|
||||
t.Fatalf("Plan = %q, want %q", got.Plan, tt.wantPlan)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartAccountStatePrefetch_SkipsWhoamiWhenCloudDisabled(t *testing.T) {
|
||||
var whoamiCalled atomic.Bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/api/status":
|
||||
fmt.Fprint(w, `{"cloud":{"disabled":true,"source":"config"}}`)
|
||||
case "/api/me":
|
||||
whoamiCalled.Store(true)
|
||||
http.NotFound(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
prefetch := StartAccountStatePrefetch(context.Background())
|
||||
select {
|
||||
case <-prefetch.done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("account prefetch did not finish")
|
||||
}
|
||||
if whoamiCalled.Load() {
|
||||
t.Fatal("prefetch should not call whoami when cloud is disabled")
|
||||
}
|
||||
state := prefetch.StateIfReady()
|
||||
if state == nil || state.Status != accountStateUnknown {
|
||||
t.Fatalf("prefetch state = %#v, want unknown", state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureAuth_PreservesCancelledSignInHook(t *testing.T) {
|
||||
oldSignIn := DefaultSignIn
|
||||
DefaultSignIn = func(modelName, signInURL string) (string, error) {
|
||||
@@ -1455,11 +1740,21 @@ 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",
|
||||
wantURL: "https://docs.openclaw.ai",
|
||||
},
|
||||
{
|
||||
name: "pool has hint",
|
||||
input: "pool",
|
||||
wantURL: "https://github.com/poolsideai/pool",
|
||||
},
|
||||
{
|
||||
name: "unknown has no hint",
|
||||
input: "unknown",
|
||||
@@ -1515,11 +1810,49 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
for _, info := range infos {
|
||||
got = append(got, info.Name)
|
||||
}
|
||||
if diff := compareStrings(got, integrationOrder); diff != "" {
|
||||
|
||||
want := append([]string(nil), integrationOrder...)
|
||||
if poolsideGOOS == "windows" {
|
||||
filtered := make([]string, 0, len(want))
|
||||
for _, name := range want {
|
||||
if name != "pool" {
|
||||
filtered = append(filtered, name)
|
||||
}
|
||||
}
|
||||
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 == "" {
|
||||
@@ -1533,6 +1866,12 @@ 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
|
||||
}
|
||||
for _, info := range infos {
|
||||
if _, ok := known[info.Name]; ok {
|
||||
known[info.Name] = true
|
||||
@@ -1568,6 +1907,26 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestListIntegrationInfos_HidesPoolsideOnWindows(t *testing.T) {
|
||||
prev := poolsideGOOS
|
||||
poolsideGOOS = "windows"
|
||||
t.Cleanup(func() { poolsideGOOS = prev })
|
||||
|
||||
for _, info := range ListIntegrationInfos() {
|
||||
if info.Name == "pool" {
|
||||
t.Fatal("expected pool to be hidden on Windows")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListIntegrationInfos_HidesClaudeDesktop(t *testing.T) {
|
||||
for _, info := range ListIntegrationInfos() {
|
||||
if info.Name == "claude-desktop" {
|
||||
t.Fatal("expected hidden claude-desktop to be absent")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildModelList_Descriptions(t *testing.T) {
|
||||
t.Run("installed recommended has base description", func(t *testing.T) {
|
||||
existing := []modelInfo{
|
||||
@@ -1594,7 +1953,7 @@ func TestBuildModelList_Descriptions(t *testing.T) {
|
||||
|
||||
for _, item := range items {
|
||||
if item.Name == "qwen3.5" {
|
||||
if !strings.Contains(item.Description, "~11GB") {
|
||||
if !strings.Contains(item.Description, "~14GB") {
|
||||
t.Errorf("not-installed qwen3.5 should show VRAM hint, got %q", item.Description)
|
||||
}
|
||||
return
|
||||
@@ -1611,7 +1970,7 @@ func TestBuildModelList_Descriptions(t *testing.T) {
|
||||
|
||||
for _, item := range items {
|
||||
if item.Name == "qwen3.5" {
|
||||
if strings.Contains(item.Description, "~11GB") {
|
||||
if strings.Contains(item.Description, "~14GB") {
|
||||
t.Errorf("installed qwen3.5 should not show VRAM hint, got %q", item.Description)
|
||||
}
|
||||
return
|
||||
@@ -1630,6 +1989,7 @@ func TestIntegration_Editor(t *testing.T) {
|
||||
{"opencode", true},
|
||||
{"openclaw", true},
|
||||
{"claude", false},
|
||||
{"claude-desktop", false},
|
||||
{"codex", false},
|
||||
{"nonexistent", false},
|
||||
}
|
||||
@@ -1656,6 +2016,7 @@ func TestIntegration_AutoInstallable(t *testing.T) {
|
||||
{"pi", true},
|
||||
{"hermes", true},
|
||||
{"claude", false},
|
||||
{"claude-desktop", false},
|
||||
{"codex", false},
|
||||
{"opencode", false},
|
||||
}
|
||||
@@ -1673,6 +2034,20 @@ func TestIntegration_AutoInstallable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureIntegrationInstalled_PoolsideUnsupportedOnWindows(t *testing.T) {
|
||||
prev := poolsideGOOS
|
||||
poolsideGOOS = "windows"
|
||||
t.Cleanup(func() { poolsideGOOS = prev })
|
||||
|
||||
err := EnsureIntegrationInstalled("pool", &Poolside{})
|
||||
if err == nil {
|
||||
t.Fatal("expected Windows unsupported error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not currently supported on Windows") {
|
||||
t.Fatalf("expected Windows warning, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationModels(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
// Kimi implements Runner for Kimi Code CLI integration.
|
||||
type Kimi struct{}
|
||||
|
||||
const (
|
||||
kimiDefaultModelAlias = "ollama"
|
||||
kimiDefaultMaxContextSize = 32768
|
||||
)
|
||||
|
||||
var (
|
||||
kimiGOOS = runtime.GOOS
|
||||
kimiModelShowTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
func (k *Kimi) String() string { return "Kimi Code CLI" }
|
||||
|
||||
func (k *Kimi) args(config string, extra []string) []string {
|
||||
args := []string{"--config", config}
|
||||
args = append(args, extra...)
|
||||
return args
|
||||
}
|
||||
|
||||
func (k *Kimi) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
return fmt.Errorf("model is required")
|
||||
}
|
||||
if err := validateKimiPassthroughArgs(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config, err := buildKimiInlineConfig(model, resolveKimiMaxContextSize(model))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to build kimi config: %w", err)
|
||||
}
|
||||
|
||||
bin, err := ensureKimiInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, k.args(config, args)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func findKimiBinary() (string, error) {
|
||||
if path, err := exec.LookPath("kimi"); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
|
||||
var candidates []string
|
||||
switch kimiGOOS {
|
||||
case "windows":
|
||||
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(home, ".local", "bin"))
|
||||
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(home, "bin"))
|
||||
|
||||
if appData := strings.TrimSpace(os.Getenv("APPDATA")); appData != "" {
|
||||
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(appData, "uv", "bin"))
|
||||
}
|
||||
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
|
||||
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(localAppData, "uv", "bin"))
|
||||
}
|
||||
default:
|
||||
candidates = append(candidates,
|
||||
filepath.Join(home, ".local", "bin", "kimi"),
|
||||
filepath.Join(home, "bin", "kimi"),
|
||||
filepath.Join(home, ".local", "share", "uv", "tools", "kimi-cli", "bin", "kimi"),
|
||||
filepath.Join(home, ".local", "share", "uv", "tools", "kimi", "bin", "kimi"),
|
||||
)
|
||||
|
||||
if xdgDataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); xdgDataHome != "" {
|
||||
candidates = append(candidates,
|
||||
filepath.Join(xdgDataHome, "uv", "tools", "kimi-cli", "bin", "kimi"),
|
||||
filepath.Join(xdgDataHome, "uv", "tools", "kimi", "bin", "kimi"),
|
||||
)
|
||||
}
|
||||
|
||||
// WSL users can inherit Windows env vars while launching from Linux shells.
|
||||
if profile := windowsPathToWSL(os.Getenv("USERPROFILE")); profile != "" {
|
||||
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(profile, ".local", "bin"))
|
||||
}
|
||||
if appData := windowsPathToWSL(os.Getenv("APPDATA")); appData != "" {
|
||||
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(appData, "uv", "bin"))
|
||||
}
|
||||
if localAppData := windowsPathToWSL(os.Getenv("LOCALAPPDATA")); localAppData != "" {
|
||||
candidates = appendWindowsKimiCandidates(candidates, filepath.Join(localAppData, "uv", "bin"))
|
||||
}
|
||||
}
|
||||
|
||||
for _, candidate := range candidates {
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("kimi binary not found")
|
||||
}
|
||||
|
||||
func appendWindowsKimiCandidates(candidates []string, dir string) []string {
|
||||
if strings.TrimSpace(dir) == "" {
|
||||
return candidates
|
||||
}
|
||||
|
||||
return append(candidates,
|
||||
filepath.Join(dir, "kimi.exe"),
|
||||
filepath.Join(dir, "kimi.cmd"),
|
||||
filepath.Join(dir, "kimi.bat"),
|
||||
)
|
||||
}
|
||||
|
||||
func windowsPathToWSL(path string) string {
|
||||
trimmed := strings.TrimSpace(path)
|
||||
if len(trimmed) < 3 || trimmed[1] != ':' {
|
||||
return ""
|
||||
}
|
||||
|
||||
drive := strings.ToLower(string(trimmed[0]))
|
||||
rest := strings.ReplaceAll(trimmed[2:], "\\", "/")
|
||||
rest = strings.TrimPrefix(rest, "/")
|
||||
if rest == "" {
|
||||
return filepath.Join("/mnt", drive)
|
||||
}
|
||||
|
||||
return filepath.Join("/mnt", drive, rest)
|
||||
}
|
||||
|
||||
func validateKimiPassthroughArgs(args []string) error {
|
||||
for _, arg := range args {
|
||||
switch {
|
||||
case arg == "--config", strings.HasPrefix(arg, "--config="):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages --config", arg)
|
||||
case arg == "--config-file", strings.HasPrefix(arg, "--config-file="):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages --config-file", arg)
|
||||
case arg == "--model", strings.HasPrefix(arg, "--model="):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages --model", arg)
|
||||
case arg == "-m", strings.HasPrefix(arg, "-m="):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch kimi manages -m/--model", arg)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildKimiInlineConfig(model string, maxContextSize int) (string, error) {
|
||||
cfg := map[string]any{
|
||||
"default_model": kimiDefaultModelAlias,
|
||||
"providers": map[string]any{
|
||||
kimiDefaultModelAlias: map[string]any{
|
||||
"type": "openai_legacy",
|
||||
"base_url": envconfig.ConnectableHost().String() + "/v1",
|
||||
"api_key": "ollama",
|
||||
},
|
||||
},
|
||||
"models": map[string]any{
|
||||
kimiDefaultModelAlias: map[string]any{
|
||||
"provider": kimiDefaultModelAlias,
|
||||
"model": model,
|
||||
"max_context_size": maxContextSize,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func resolveKimiMaxContextSize(model string) int {
|
||||
if l, ok := lookupCloudModelLimit(model); ok {
|
||||
return l.Context
|
||||
}
|
||||
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return kimiDefaultMaxContextSize
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), kimiModelShowTimeout)
|
||||
defer cancel()
|
||||
resp, err := client.Show(ctx, &api.ShowRequest{Model: model})
|
||||
if err != nil {
|
||||
return kimiDefaultMaxContextSize
|
||||
}
|
||||
|
||||
if n, ok := modelInfoContextLength(resp.ModelInfo); ok {
|
||||
return n
|
||||
}
|
||||
|
||||
return kimiDefaultMaxContextSize
|
||||
}
|
||||
|
||||
func modelInfoContextLength(modelInfo map[string]any) (int, bool) {
|
||||
for key, val := range modelInfo {
|
||||
if !strings.HasSuffix(key, ".context_length") {
|
||||
continue
|
||||
}
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
if v > 0 {
|
||||
return int(v), true
|
||||
}
|
||||
case int:
|
||||
if v > 0 {
|
||||
return v, true
|
||||
}
|
||||
case int64:
|
||||
if v > 0 {
|
||||
return int(v), true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func ensureKimiInstalled() (string, error) {
|
||||
if path, err := findKimiBinary(); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
|
||||
if err := checkKimiInstallerDependencies(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Kimi is not installed. Install now?")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("kimi installation cancelled")
|
||||
}
|
||||
|
||||
bin, args, err := kimiInstallerCommand(kimiGOOS)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Kimi...\n")
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to install kimi: %w", err)
|
||||
}
|
||||
|
||||
path, err := findKimiBinary()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("kimi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sKimi installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func checkKimiInstallerDependencies() error {
|
||||
switch kimiGOOS {
|
||||
case "windows":
|
||||
if _, err := exec.LookPath("powershell"); err != nil {
|
||||
return fmt.Errorf("kimi is not installed and required dependencies are missing\n\nInstall the following first:\n PowerShell: https://learn.microsoft.com/powershell/\n\nThen re-run:\n ollama launch kimi")
|
||||
}
|
||||
default:
|
||||
var missing []string
|
||||
if _, err := exec.LookPath("curl"); err != nil {
|
||||
missing = append(missing, "curl: https://curl.se/")
|
||||
}
|
||||
if _, err := exec.LookPath("bash"); err != nil {
|
||||
missing = append(missing, "bash: https://www.gnu.org/software/bash/")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("kimi is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch kimi", strings.Join(missing, "\n "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func kimiInstallerCommand(goos string) (string, []string, error) {
|
||||
switch goos {
|
||||
case "windows":
|
||||
return "powershell", []string{
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
"Invoke-RestMethod https://code.kimi.com/install.ps1 | Invoke-Expression",
|
||||
}, nil
|
||||
case "darwin", "linux":
|
||||
return "bash", []string{
|
||||
"-c",
|
||||
"curl -LsSf https://code.kimi.com/install.sh | bash",
|
||||
}, nil
|
||||
default:
|
||||
return "", nil, fmt.Errorf("unsupported platform for kimi install: %s", goos)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func assertKimiBinPath(t *testing.T, bin string) {
|
||||
t.Helper()
|
||||
base := strings.ToLower(filepath.Base(bin))
|
||||
if !strings.HasPrefix(base, "kimi") {
|
||||
t.Fatalf("bin = %q, want path to kimi executable", bin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiIntegration(t *testing.T) {
|
||||
k := &Kimi{}
|
||||
|
||||
t.Run("String", func(t *testing.T) {
|
||||
if got := k.String(); got != "Kimi Code CLI" {
|
||||
t.Errorf("String() = %q, want %q", got, "Kimi Code CLI")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("implements Runner", func(t *testing.T) {
|
||||
var _ Runner = k
|
||||
})
|
||||
}
|
||||
|
||||
func TestKimiArgs(t *testing.T) {
|
||||
k := &Kimi{}
|
||||
|
||||
got := k.args(`{"foo":"bar"}`, []string{"--quiet", "--print"})
|
||||
want := []string{"--config", `{"foo":"bar"}`, "--quiet", "--print"}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("args() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsPathToWSL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
valid bool
|
||||
}{
|
||||
{
|
||||
name: "user profile path",
|
||||
in: `C:\Users\parth`,
|
||||
want: filepath.Join("/mnt", "c", "Users", "parth"),
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "path with trailing slash",
|
||||
in: `D:\tools\bin\`,
|
||||
want: filepath.Join("/mnt", "d", "tools", "bin"),
|
||||
valid: true,
|
||||
},
|
||||
{
|
||||
name: "non windows path",
|
||||
in: "/home/parth",
|
||||
valid: false,
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
in: "",
|
||||
valid: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := windowsPathToWSL(tt.in)
|
||||
if !tt.valid {
|
||||
if got != "" {
|
||||
t.Fatalf("windowsPathToWSL(%q) = %q, want empty", tt.in, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("windowsPathToWSL(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindKimiBinaryFallbacks(t *testing.T) {
|
||||
oldGOOS := kimiGOOS
|
||||
t.Cleanup(func() { kimiGOOS = oldGOOS })
|
||||
|
||||
t.Run("linux/ubuntu uv tool path", func(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
setTestHome(t, homeDir)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
kimiGOOS = "linux"
|
||||
|
||||
target := filepath.Join(homeDir, ".local", "share", "uv", "tools", "kimi-cli", "bin", "kimi")
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
t.Fatalf("failed to create candidate dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write kimi candidate: %v", err)
|
||||
}
|
||||
|
||||
got, err := findKimiBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("findKimiBinary() error = %v", err)
|
||||
}
|
||||
if got != target {
|
||||
t.Fatalf("findKimiBinary() = %q, want %q", got, target)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("windows appdata uv bin", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
kimiGOOS = "windows"
|
||||
|
||||
appDataDir := t.TempDir()
|
||||
t.Setenv("APPDATA", appDataDir)
|
||||
t.Setenv("LOCALAPPDATA", "")
|
||||
|
||||
target := filepath.Join(appDataDir, "uv", "bin", "kimi.cmd")
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
t.Fatalf("failed to create candidate dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("@echo off\r\nexit /b 0\r\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write kimi candidate: %v", err)
|
||||
}
|
||||
|
||||
got, err := findKimiBinary()
|
||||
if err != nil {
|
||||
t.Fatalf("findKimiBinary() error = %v", err)
|
||||
}
|
||||
if got != target {
|
||||
t.Fatalf("findKimiBinary() = %q, want %q", got, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateKimiPassthroughArgs_RejectsConflicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "--config", args: []string{"--config", "{}"}, want: "--config"},
|
||||
{name: "--config=", args: []string{"--config={}"}, want: "--config={"},
|
||||
{name: "--config-file", args: []string{"--config-file", "x.toml"}, want: "--config-file"},
|
||||
{name: "--config-file=", args: []string{"--config-file=x.toml"}, want: "--config-file=x.toml"},
|
||||
{name: "--model", args: []string{"--model", "foo"}, want: "--model"},
|
||||
{name: "--model=", args: []string{"--model=foo"}, want: "--model=foo"},
|
||||
{name: "-m", args: []string{"-m", "foo"}, want: "-m"},
|
||||
{name: "-m=", args: []string{"-m=foo"}, want: "-m=foo"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateKimiPassthroughArgs(tt.args)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for args %v", tt.args)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.want) {
|
||||
t.Fatalf("error %q does not contain %q", err.Error(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKimiInlineConfig(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||
|
||||
cfg, err := buildKimiInlineConfig("llama3.2", 65536)
|
||||
if err != nil {
|
||||
t.Fatalf("buildKimiInlineConfig() error = %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(cfg), &parsed); err != nil {
|
||||
t.Fatalf("config is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if parsed["default_model"] != "ollama" {
|
||||
t.Fatalf("default_model = %v, want ollama", parsed["default_model"])
|
||||
}
|
||||
|
||||
providers, ok := parsed["providers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("providers missing or wrong type: %T", parsed["providers"])
|
||||
}
|
||||
ollamaProvider, ok := providers["ollama"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("providers.ollama missing or wrong type: %T", providers["ollama"])
|
||||
}
|
||||
if ollamaProvider["type"] != "openai_legacy" {
|
||||
t.Fatalf("provider type = %v, want openai_legacy", ollamaProvider["type"])
|
||||
}
|
||||
if ollamaProvider["base_url"] != "http://127.0.0.1:11434/v1" {
|
||||
t.Fatalf("provider base_url = %v, want http://127.0.0.1:11434/v1", ollamaProvider["base_url"])
|
||||
}
|
||||
if ollamaProvider["api_key"] != "ollama" {
|
||||
t.Fatalf("provider api_key = %v, want ollama", ollamaProvider["api_key"])
|
||||
}
|
||||
|
||||
models, ok := parsed["models"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("models missing or wrong type: %T", parsed["models"])
|
||||
}
|
||||
ollamaModel, ok := models["ollama"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("models.ollama missing or wrong type: %T", models["ollama"])
|
||||
}
|
||||
if ollamaModel["provider"] != "ollama" {
|
||||
t.Fatalf("model provider = %v, want ollama", ollamaModel["provider"])
|
||||
}
|
||||
if ollamaModel["model"] != "llama3.2" {
|
||||
t.Fatalf("model model = %v, want llama3.2", ollamaModel["model"])
|
||||
}
|
||||
if ollamaModel["max_context_size"] != float64(65536) {
|
||||
t.Fatalf("model max_context_size = %v, want 65536", ollamaModel["max_context_size"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildKimiInlineConfig_UsesConnectableHostForUnspecifiedBind(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
|
||||
|
||||
cfg, err := buildKimiInlineConfig("llama3.2", 65536)
|
||||
if err != nil {
|
||||
t.Fatalf("buildKimiInlineConfig() error = %v", err)
|
||||
}
|
||||
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(cfg), &parsed); err != nil {
|
||||
t.Fatalf("config is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
providers, ok := parsed["providers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("providers missing or wrong type: %T", parsed["providers"])
|
||||
}
|
||||
|
||||
ollamaProvider, ok := providers["ollama"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("providers.ollama missing or wrong type: %T", providers["ollama"])
|
||||
}
|
||||
if got, _ := ollamaProvider["base_url"].(string); got != "http://127.0.0.1:11434/v1" {
|
||||
t.Fatalf("provider base_url = %q, want %q", got, "http://127.0.0.1:11434/v1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveKimiMaxContextSize(t *testing.T) {
|
||||
t.Run("uses cloud limit when known", func(t *testing.T) {
|
||||
got := resolveKimiMaxContextSize("kimi-k2.5:cloud")
|
||||
if got != 262_144 {
|
||||
t.Fatalf("resolveKimiMaxContextSize() = %d, want 262144", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("uses model show context length for local models", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/api/show" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"model_info":{"llama.context_length":131072}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
got := resolveKimiMaxContextSize("llama3.2")
|
||||
if got != 131_072 {
|
||||
t.Fatalf("resolveKimiMaxContextSize() = %d, want 131072", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to default when show fails", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
oldTimeout := kimiModelShowTimeout
|
||||
kimiModelShowTimeout = 100 * 1000 * 1000 // 100ms
|
||||
t.Cleanup(func() { kimiModelShowTimeout = oldTimeout })
|
||||
|
||||
got := resolveKimiMaxContextSize("llama3.2")
|
||||
if got != kimiDefaultMaxContextSize {
|
||||
t.Fatalf("resolveKimiMaxContextSize() = %d, want %d", got, kimiDefaultMaxContextSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKimiRun_RejectsConflictingArgsBeforeInstall(t *testing.T) {
|
||||
k := &Kimi{}
|
||||
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect install prompt, got %q", prompt)
|
||||
return false, nil
|
||||
}
|
||||
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKimiRun_PassesInlineConfigAndExtraArgs(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binary")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
logPath := filepath.Join(tmpDir, "kimi-args.log")
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
for arg in "$@"; do
|
||||
printf "%%s\n" "$arg" >> %q
|
||||
done
|
||||
exit 0
|
||||
`, logPath)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "kimi"), []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake kimi: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
k := &Kimi{}
|
||||
if err := k.Run("llama3.2", nil, []string{"--quiet", "--print"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read args log: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
if len(lines) < 4 {
|
||||
t.Fatalf("expected at least 4 args, got %v", lines)
|
||||
}
|
||||
if lines[0] != "--config" {
|
||||
t.Fatalf("first arg = %q, want --config", lines[0])
|
||||
}
|
||||
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[1]), &cfg); err != nil {
|
||||
t.Fatalf("config arg is not valid JSON: %v", err)
|
||||
}
|
||||
providers := cfg["providers"].(map[string]any)
|
||||
ollamaProvider := providers["ollama"].(map[string]any)
|
||||
if ollamaProvider["type"] != "openai_legacy" {
|
||||
t.Fatalf("provider type = %v, want openai_legacy", ollamaProvider["type"])
|
||||
}
|
||||
|
||||
if lines[2] != "--quiet" || lines[3] != "--print" {
|
||||
t.Fatalf("extra args = %v, want [--quiet --print]", lines[2:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureKimiInstalled(t *testing.T) {
|
||||
oldGOOS := kimiGOOS
|
||||
t.Cleanup(func() { kimiGOOS = oldGOOS })
|
||||
|
||||
withConfirm := func(t *testing.T, fn func(prompt string) (bool, error)) {
|
||||
t.Helper()
|
||||
oldConfirm := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
return fn(prompt)
|
||||
}
|
||||
t.Cleanup(func() { DefaultConfirmPrompt = oldConfirm })
|
||||
}
|
||||
|
||||
t.Run("already installed", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
writeFakeBinary(t, tmpDir, "kimi")
|
||||
kimiGOOS = runtime.GOOS
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
bin, err := ensureKimiInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureKimiInstalled() error = %v", err)
|
||||
}
|
||||
assertKimiBinPath(t, bin)
|
||||
})
|
||||
|
||||
t.Run("missing dependencies", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
kimiGOOS = "linux"
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
t.Fatalf("did not expect prompt, got %q", prompt)
|
||||
return false, nil
|
||||
})
|
||||
|
||||
_, err := ensureKimiInstalled()
|
||||
if err == nil || !strings.Contains(err.Error(), "required dependencies are missing") {
|
||||
t.Fatalf("expected missing dependency error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing and user declines install", func(t *testing.T) {
|
||||
setTestHome(t, t.TempDir())
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
writeFakeBinary(t, tmpDir, "curl")
|
||||
writeFakeBinary(t, tmpDir, "bash")
|
||||
kimiGOOS = "linux"
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
if !strings.Contains(prompt, "Kimi is not installed.") {
|
||||
t.Fatalf("unexpected prompt: %q", prompt)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
|
||||
_, err := ensureKimiInstalled()
|
||||
if err == nil || !strings.Contains(err.Error(), "installation cancelled") {
|
||||
t.Fatalf("expected cancellation error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing and user confirms install succeeds", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binaries")
|
||||
}
|
||||
|
||||
setTestHome(t, t.TempDir())
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
kimiGOOS = "linux"
|
||||
|
||||
writeFakeBinary(t, tmpDir, "curl")
|
||||
|
||||
installLog := filepath.Join(tmpDir, "bash.log")
|
||||
kimiPath := filepath.Join(tmpDir, "kimi")
|
||||
bashScript := fmt.Sprintf(`#!/bin/sh
|
||||
echo "$@" >> %q
|
||||
if [ "$1" = "-c" ]; then
|
||||
/bin/cat > %q <<'EOS'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOS
|
||||
/bin/chmod +x %q
|
||||
fi
|
||||
exit 0
|
||||
`, installLog, kimiPath, kimiPath)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte(bashScript), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake bash: %v", err)
|
||||
}
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
bin, err := ensureKimiInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureKimiInstalled() error = %v", err)
|
||||
}
|
||||
assertKimiBinPath(t, bin)
|
||||
|
||||
logData, err := os.ReadFile(installLog)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read install log: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(logData), "https://code.kimi.com/install.sh") {
|
||||
t.Fatalf("expected install.sh command in log, got:\n%s", string(logData))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("install succeeds and kimi is in home local bin without PATH update", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binaries")
|
||||
}
|
||||
|
||||
homeDir := t.TempDir()
|
||||
setTestHome(t, homeDir)
|
||||
|
||||
tmpBin := t.TempDir()
|
||||
t.Setenv("PATH", tmpBin)
|
||||
kimiGOOS = "linux"
|
||||
writeFakeBinary(t, tmpBin, "curl")
|
||||
|
||||
installedKimi := filepath.Join(homeDir, ".local", "bin", "kimi")
|
||||
bashScript := fmt.Sprintf(`#!/bin/sh
|
||||
if [ "$1" = "-c" ]; then
|
||||
/bin/mkdir -p %q
|
||||
/bin/cat > %q <<'EOS'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOS
|
||||
/bin/chmod +x %q
|
||||
fi
|
||||
exit 0
|
||||
`, filepath.Dir(installedKimi), installedKimi, installedKimi)
|
||||
if err := os.WriteFile(filepath.Join(tmpBin, "bash"), []byte(bashScript), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake bash: %v", err)
|
||||
}
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
bin, err := ensureKimiInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureKimiInstalled() error = %v", err)
|
||||
}
|
||||
if bin != installedKimi {
|
||||
t.Fatalf("bin = %q, want %q", bin, installedKimi)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("install command fails", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binaries")
|
||||
}
|
||||
|
||||
setTestHome(t, t.TempDir())
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
kimiGOOS = "linux"
|
||||
writeFakeBinary(t, tmpDir, "curl")
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake bash: %v", err)
|
||||
}
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
_, err := ensureKimiInstalled()
|
||||
if err == nil || !strings.Contains(err.Error(), "failed to install kimi") {
|
||||
t.Fatalf("expected install failure error, got %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("install succeeds but binary missing on PATH", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binaries")
|
||||
}
|
||||
|
||||
setTestHome(t, t.TempDir())
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("PATH", tmpDir)
|
||||
kimiGOOS = "linux"
|
||||
writeFakeBinary(t, tmpDir, "curl")
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "bash"), []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake bash: %v", err)
|
||||
}
|
||||
|
||||
withConfirm(t, func(prompt string) (bool, error) {
|
||||
return true, nil
|
||||
})
|
||||
|
||||
_, err := ensureKimiInstalled()
|
||||
if err == nil || !strings.Contains(err.Error(), "binary was not found on PATH") {
|
||||
t.Fatalf("expected PATH guidance error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKimiInstallerCommand(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
wantBin string
|
||||
wantParts []string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "linux",
|
||||
goos: "linux",
|
||||
wantBin: "bash",
|
||||
wantParts: []string{"-c", "install.sh"},
|
||||
},
|
||||
{
|
||||
name: "darwin",
|
||||
goos: "darwin",
|
||||
wantBin: "bash",
|
||||
wantParts: []string{"-c", "install.sh"},
|
||||
},
|
||||
{
|
||||
name: "windows",
|
||||
goos: "windows",
|
||||
wantBin: "powershell",
|
||||
wantParts: []string{"-Command", "install.ps1"},
|
||||
},
|
||||
{
|
||||
name: "unsupported",
|
||||
goos: "freebsd",
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
bin, args, err := kimiInstallerCommand(tt.goos)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("kimiInstallerCommand() error = %v", err)
|
||||
}
|
||||
if bin != tt.wantBin {
|
||||
t.Fatalf("bin = %q, want %q", bin, tt.wantBin)
|
||||
}
|
||||
joined := strings.Join(args, " ")
|
||||
for _, part := range tt.wantParts {
|
||||
if !strings.Contains(joined, part) {
|
||||
t.Fatalf("args %q missing %q", joined, part)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+570
-111
File diff suppressed because it is too large.
Load diff
+1141
-83
File diff suppressed because it is too large.
Load diff
@@ -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])
|
||||
}
|
||||
}
|
||||
+141
-72
@@ -4,34 +4,42 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/format"
|
||||
internalcloud "github.com/ollama/ollama/internal/cloud"
|
||||
"github.com/ollama/ollama/internal/modelref"
|
||||
"github.com/ollama/ollama/progress"
|
||||
)
|
||||
|
||||
var recommendedModels = []ModelItem{
|
||||
{Name: "kimi-k2.5:cloud", Description: "Multimodal reasoning with subagents", Recommended: true},
|
||||
{Name: "qwen3.5:cloud", Description: "Reasoning, coding, and agentic tool use with vision", Recommended: true},
|
||||
{Name: "glm-5.1:cloud", Description: "Reasoning and code generation", Recommended: true},
|
||||
{Name: "minimax-m2.7:cloud", Description: "Fast, efficient coding and real-world productivity", Recommended: true},
|
||||
{Name: "gemma4", Description: "Reasoning and code generation locally", Recommended: true},
|
||||
{Name: "qwen3.5", Description: "Reasoning, coding, and visual understanding locally", Recommended: true},
|
||||
{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},
|
||||
}
|
||||
|
||||
var recommendedVRAM = map[string]string{
|
||||
"gemma4": "~16GB",
|
||||
"qwen3.5": "~11GB",
|
||||
func displayVRAM(vramBytes int64) string {
|
||||
if vramBytes <= 0 {
|
||||
return ""
|
||||
}
|
||||
gb := float64(vramBytes) / format.GigaByte
|
||||
if gb == math.Trunc(gb) {
|
||||
return fmt.Sprintf("~%.0fGB", gb)
|
||||
}
|
||||
return fmt.Sprintf("~%.1fGB", gb)
|
||||
}
|
||||
|
||||
// cloudModelLimit holds context and output token limits for a cloud model.
|
||||
@@ -40,10 +48,10 @@ type cloudModelLimit struct {
|
||||
Output int
|
||||
}
|
||||
|
||||
// cloudModelLimits maps cloud model base names to their token limits.
|
||||
// extraCloudModelLimits maps cloud model base names to token limits for models
|
||||
// that are not already covered by recommendedModels fallback entries.
|
||||
// TODO(parthsareen): grab context/output limits from model info instead of hardcoding
|
||||
var cloudModelLimits = map[string]cloudModelLimit{
|
||||
"minimax-m2.7": {Context: 204_800, Output: 128_000},
|
||||
var extraCloudModelLimits = map[string]cloudModelLimit{
|
||||
"cogito-2.1:671b": {Context: 163_840, Output: 65_536},
|
||||
"deepseek-v3.1:671b": {Context: 163_840, Output: 163_840},
|
||||
"deepseek-v3.2": {Context: 163_840, Output: 65_536},
|
||||
@@ -56,6 +64,7 @@ var cloudModelLimits = map[string]cloudModelLimit{
|
||||
"gpt-oss:20b": {Context: 131_072, Output: 131_072},
|
||||
"kimi-k2:1t": {Context: 262_144, Output: 262_144},
|
||||
"kimi-k2.5": {Context: 262_144, Output: 262_144},
|
||||
"kimi-k2.6": {Context: 262_144, Output: 262_144},
|
||||
"kimi-k2-thinking": {Context: 262_144, Output: 262_144},
|
||||
"nemotron-3-nano:30b": {Context: 1_048_576, Output: 131_072},
|
||||
"qwen3-coder:480b": {Context: 262_144, Output: 65_536},
|
||||
@@ -64,11 +73,24 @@ var cloudModelLimits = map[string]cloudModelLimit{
|
||||
"qwen3.5": {Context: 262_144, Output: 32_768},
|
||||
}
|
||||
|
||||
var cloudModelLimits = mergeCloudModelLimits(cloudModelLimitsFromRecommendations(recommendedModels), extraCloudModelLimits)
|
||||
|
||||
var (
|
||||
dynamicCloudModelLimitsMu sync.RWMutex
|
||||
dynamicCloudModelLimits = map[string]cloudModelLimit{}
|
||||
)
|
||||
|
||||
// lookupCloudModelLimit returns the token limits for a cloud model.
|
||||
// It normalizes explicit cloud source suffixes before checking the shared limit map.
|
||||
func lookupCloudModelLimit(name string) (cloudModelLimit, bool) {
|
||||
base, stripped := modelref.StripCloudSourceTag(name)
|
||||
if stripped {
|
||||
dynamicCloudModelLimitsMu.RLock()
|
||||
l, ok := dynamicCloudModelLimits[base]
|
||||
dynamicCloudModelLimitsMu.RUnlock()
|
||||
if ok {
|
||||
return l, true
|
||||
}
|
||||
if l, ok := cloudModelLimits[base]; ok {
|
||||
return l, true
|
||||
}
|
||||
@@ -76,6 +98,49 @@ func lookupCloudModelLimit(name string) (cloudModelLimit, bool) {
|
||||
return cloudModelLimit{}, false
|
||||
}
|
||||
|
||||
func setDynamicCloudModelLimits(limits map[string]cloudModelLimit) {
|
||||
dynamicCloudModelLimitsMu.Lock()
|
||||
defer dynamicCloudModelLimitsMu.Unlock()
|
||||
if limits == nil {
|
||||
dynamicCloudModelLimits = map[string]cloudModelLimit{}
|
||||
return
|
||||
}
|
||||
cp := make(map[string]cloudModelLimit, len(limits))
|
||||
for k, v := range limits {
|
||||
cp[k] = v
|
||||
}
|
||||
dynamicCloudModelLimits = cp
|
||||
}
|
||||
|
||||
func cloudModelLimitsFromRecommendations(recommendations []ModelItem) map[string]cloudModelLimit {
|
||||
limits := make(map[string]cloudModelLimit, len(recommendations))
|
||||
for _, rec := range recommendations {
|
||||
if !isCloudModelName(rec.Name) || rec.Details.ContextLength <= 0 || rec.MaxOutputTokens <= 0 {
|
||||
continue
|
||||
}
|
||||
base, stripped := modelref.StripCloudSourceTag(rec.Name)
|
||||
if !stripped || base == "" {
|
||||
continue
|
||||
}
|
||||
limits[base] = cloudModelLimit{
|
||||
Context: rec.Details.ContextLength,
|
||||
Output: rec.MaxOutputTokens,
|
||||
}
|
||||
}
|
||||
return limits
|
||||
}
|
||||
|
||||
func mergeCloudModelLimits(base map[string]cloudModelLimit, overlay map[string]cloudModelLimit) map[string]cloudModelLimit {
|
||||
out := make(map[string]cloudModelLimit, len(base)+len(overlay))
|
||||
for name, limit := range base {
|
||||
out[name] = limit
|
||||
}
|
||||
for name, limit := range overlay {
|
||||
out[name] = limit
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// missingModelPolicy controls how model-not-found errors should be handled.
|
||||
type missingModelPolicy int
|
||||
|
||||
@@ -115,22 +180,27 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string]
|
||||
if len(selectedCloudModels) == 0 {
|
||||
return nil
|
||||
}
|
||||
return ensureCloudAuth(ctx, client, strings.Join(selectedCloudModels, ", "))
|
||||
}
|
||||
|
||||
func ensureCloudAuth(ctx context.Context, client *api.Client, modelList string) error {
|
||||
if disabled, known := cloudStatusDisabled(ctx, client); known && disabled {
|
||||
return errors.New(internalcloud.DisabledError("remote inference is unavailable"))
|
||||
}
|
||||
|
||||
user, err := client.Whoami(ctx)
|
||||
user, err := whoamiWithTimeout(ctx, client)
|
||||
if err == nil && user != nil && user.Name != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
var aErr api.AuthorizationError
|
||||
if !errors.As(err, &aErr) || aErr.SigninURL == "" {
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%s requires sign in", modelList)
|
||||
}
|
||||
|
||||
modelList := strings.Join(selectedCloudModels, ", ")
|
||||
|
||||
if DefaultSignIn != nil {
|
||||
_, err := DefaultSignIn(modelList, aErr.SigninURL)
|
||||
if errors.Is(err, ErrCancelled) {
|
||||
@@ -173,7 +243,7 @@ func ensureAuth(ctx context.Context, client *api.Client, cloudModels map[string]
|
||||
fmt.Fprintf(os.Stderr, "\r\033[90mwaiting for sign in to complete... %s\033[0m", spinnerFrames[frame%len(spinnerFrames)])
|
||||
|
||||
if frame%10 == 0 {
|
||||
u, err := client.Whoami(ctx)
|
||||
u, err := whoamiWithTimeout(ctx, client)
|
||||
if err == nil && u != nil && u.Name != "" {
|
||||
fmt.Fprintf(os.Stderr, "\r\033[K\033[A\r\033[K\033[1msigned in:\033[0m %s\n", u.Name)
|
||||
return nil
|
||||
@@ -229,28 +299,24 @@ func pullMissingModel(ctx context.Context, client *api.Client, model string) err
|
||||
}
|
||||
|
||||
// prepareEditorIntegration persists models and applies editor-managed config files.
|
||||
func prepareEditorIntegration(name string, runner Runner, editor Editor, models []string) error {
|
||||
if ok, err := confirmConfigEdit(runner, editor.Paths()); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return errCancelled
|
||||
}
|
||||
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, runner Runner, managed ManagedSingleModel, model string) error {
|
||||
if ok, err := confirmConfigEdit(runner, managed.Paths()); err != nil {
|
||||
return err
|
||||
} else if !ok {
|
||||
return errCancelled
|
||||
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)
|
||||
} else {
|
||||
err = managed.Configure(model)
|
||||
}
|
||||
if err := managed.Configure(model); err != nil {
|
||||
if err != nil {
|
||||
return fmt.Errorf("setup failed: %w", err)
|
||||
}
|
||||
if err := config.SaveIntegration(name, []string{model}); err != nil {
|
||||
@@ -259,31 +325,33 @@ func prepareManagedSingleIntegration(name string, runner Runner, managed Managed
|
||||
return nil
|
||||
}
|
||||
|
||||
func confirmConfigEdit(runner Runner, paths []string) (bool, error) {
|
||||
if len(paths) == 0 {
|
||||
return true, nil
|
||||
func prepareManagedAutodiscoveryIntegration(name string, autodiscovery ManagedAutodiscoveryIntegration, model string) error {
|
||||
if err := autodiscovery.ConfigureAutodiscovery(); err != nil {
|
||||
return fmt.Errorf("setup failed: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "This will modify your %s configuration:\n", runner)
|
||||
for _, path := range paths {
|
||||
fmt.Fprintf(os.Stderr, " %s\n", path)
|
||||
if err := config.SaveIntegration(name, []string{model}); err != nil {
|
||||
return fmt.Errorf("failed to save: %w", err)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Backups will be saved to %s/\n\n", fileutil.BackupDir())
|
||||
|
||||
return ConfirmPrompt("Proceed?")
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildModelList merges existing models with recommendations for selection UIs.
|
||||
func buildModelList(existing []modelInfo, preChecked []string, current string) (items []ModelItem, orderedChecked []string, existingModels, cloudModels map[string]bool) {
|
||||
return buildModelListWithRecommendations(existing, recommendedModels, preChecked, current)
|
||||
}
|
||||
|
||||
func buildModelListWithRecommendations(existing []modelInfo, recommendations []ModelItem, preChecked []string, current string) (items []ModelItem, orderedChecked []string, existingModels, cloudModels map[string]bool) {
|
||||
existingModels = make(map[string]bool)
|
||||
cloudModels = make(map[string]bool)
|
||||
recommended := make(map[string]bool)
|
||||
var hasLocalModel, hasCloudModel bool
|
||||
|
||||
recDesc := make(map[string]string)
|
||||
for _, rec := range recommendedModels {
|
||||
recByName := make(map[string]ModelItem)
|
||||
for _, rec := range recommendations {
|
||||
recommended[rec.Name] = true
|
||||
recDesc[rec.Name] = rec.Description
|
||||
recByName[rec.Name] = rec
|
||||
}
|
||||
|
||||
for _, m := range existing {
|
||||
@@ -296,11 +364,14 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
}
|
||||
displayName := strings.TrimSuffix(m.Name, ":latest")
|
||||
existingModels[displayName] = true
|
||||
item := ModelItem{Name: displayName, Recommended: recommended[displayName], Description: recDesc[displayName]}
|
||||
items = append(items, item)
|
||||
if rec, ok := recByName[displayName]; ok {
|
||||
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]}))
|
||||
}
|
||||
}
|
||||
|
||||
for _, rec := range recommendedModels {
|
||||
for _, rec := range recommendations {
|
||||
if existingModels[rec.Name] || existingModels[rec.Name+":latest"] {
|
||||
continue
|
||||
}
|
||||
@@ -346,7 +417,7 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
if items[i].Description != "" {
|
||||
parts = append(parts, items[i].Description)
|
||||
}
|
||||
if vram := recommendedVRAM[items[i].Name]; vram != "" {
|
||||
if vram := displayVRAM(items[i].VRAMBytes); vram != "" {
|
||||
parts = append(parts, vram)
|
||||
}
|
||||
parts = append(parts, "(not downloaded)")
|
||||
@@ -355,23 +426,17 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
}
|
||||
|
||||
recRank := make(map[string]int)
|
||||
for i, rec := range recommendedModels {
|
||||
for i, rec := range recommendations {
|
||||
recRank[rec.Name] = i + 1
|
||||
}
|
||||
|
||||
if hasLocalModel || hasCloudModel {
|
||||
// Keep the Recommended section pinned to recommendation order. Checked
|
||||
// and default-model priority only apply within the More section.
|
||||
slices.SortStableFunc(items, func(a, b ModelItem) int {
|
||||
ac, bc := checked[a.Name], checked[b.Name]
|
||||
aNew, bNew := notInstalled[a.Name], notInstalled[b.Name]
|
||||
aRec, bRec := recRank[a.Name] > 0, recRank[b.Name] > 0
|
||||
aCloud, bCloud := cloudModels[a.Name], cloudModels[b.Name]
|
||||
|
||||
if ac != bc {
|
||||
if ac {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if aRec != bRec {
|
||||
if aRec {
|
||||
return -1
|
||||
@@ -379,14 +444,14 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
return 1
|
||||
}
|
||||
if aRec && bRec {
|
||||
if aCloud != bCloud {
|
||||
if aCloud {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return recRank[a.Name] - recRank[b.Name]
|
||||
}
|
||||
if ac != bc {
|
||||
if ac {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
// Among checked non-recommended items - put the default first
|
||||
if ac && !aRec && current != "" {
|
||||
aCurrent := a.Name == current
|
||||
@@ -411,22 +476,26 @@ func buildModelList(existing []modelInfo, preChecked []string, current string) (
|
||||
return items, preChecked, existingModels, cloudModels
|
||||
}
|
||||
|
||||
func copyModelRecommendationFields(name string, rec ModelItem) ModelItem {
|
||||
rec.Name = name
|
||||
rec.Recommended = true
|
||||
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)
|
||||
}
|
||||
}
|
||||
+221
-239
@@ -1,7 +1,6 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -10,31 +9,25 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"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
|
||||
|
||||
var openclawCanInstallDaemon = canInstallDaemon
|
||||
|
||||
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
|
||||
@@ -60,6 +53,7 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
// the newest wizard flags (e.g. --auth-choice ollama).
|
||||
if !openclawFreshInstall {
|
||||
update := exec.Command(bin, "update")
|
||||
update.Env = openclawInstallEnv()
|
||||
update.Stdout = os.Stdout
|
||||
update.Stderr = os.Stderr
|
||||
_ = update.Run() // best-effort; continue even if update fails
|
||||
@@ -75,19 +69,18 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
"--auth-choice", "ollama",
|
||||
"--custom-base-url", envconfig.Host().String(),
|
||||
"--custom-model-id", model,
|
||||
// Launch owns the first real gateway startup immediately after onboarding,
|
||||
// so don't let OpenClaw fail the whole first-run flow on a transient
|
||||
// daemon health probe.
|
||||
"--skip-health",
|
||||
"--skip-channels",
|
||||
"--skip-skills",
|
||||
}
|
||||
if canInstallDaemon() {
|
||||
if openclawCanInstallDaemon() {
|
||||
onboardArgs = append(onboardArgs, "--install-daemon")
|
||||
} else {
|
||||
// When we can't install a daemon (e.g. no systemd, sudo dropped
|
||||
// XDG_RUNTIME_DIR, or container environment), skip the gateway
|
||||
// health check so non-interactive onboarding completes. The
|
||||
// gateway is started as a foreground child process after onboarding.
|
||||
onboardArgs = append(onboardArgs, "--skip-health")
|
||||
}
|
||||
cmd := exec.Command(bin, onboardArgs...)
|
||||
cmd.Env = openclawInstallEnv()
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -98,13 +91,23 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
patchDeviceScopes()
|
||||
}
|
||||
|
||||
if ensureWebSearchPlugin() {
|
||||
registerWebSearchPlugin()
|
||||
}
|
||||
configureOllamaWebSearch()
|
||||
|
||||
// When extra args are passed through, run exactly what the user asked for
|
||||
// after setup and skip the built-in gateway+TUI convenience flow.
|
||||
if len(args) > 0 {
|
||||
cleanup := func() {}
|
||||
if shouldEnsureGatewayForArgs(args) {
|
||||
cleanupFn, _, _, err := c.ensureGatewayReady(bin)
|
||||
if err != nil {
|
||||
return windowsHint(err)
|
||||
}
|
||||
if cleanupFn != nil {
|
||||
cleanup = cleanupFn
|
||||
}
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
cmd := exec.Command(bin, args...)
|
||||
cmd.Env = openclawEnv()
|
||||
cmd.Stdin = os.Stdin
|
||||
@@ -125,41 +128,11 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\n%sStarting your assistant — this may take a moment...%s\n\n", ansiGray, ansiReset)
|
||||
|
||||
token, port := c.gatewayInfo()
|
||||
addr := fmt.Sprintf("localhost:%d", port)
|
||||
|
||||
// If the gateway is already running (e.g. via the daemon), restart it
|
||||
// so it picks up any config changes (model, provider, etc.).
|
||||
if portOpen(addr) {
|
||||
restart := exec.Command(bin, "daemon", "restart")
|
||||
restart.Env = openclawEnv()
|
||||
if err := restart.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: daemon restart failed: %v%s\n", ansiYellow, err, ansiReset)
|
||||
}
|
||||
if !waitForPort(addr, 10*time.Second) {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: gateway did not come back after restart%s\n", ansiYellow, ansiReset)
|
||||
}
|
||||
}
|
||||
|
||||
// If the gateway isn't running, start it as a background child process.
|
||||
if !portOpen(addr) {
|
||||
gw := exec.Command(bin, "gateway", "run", "--force")
|
||||
gw.Env = openclawEnv()
|
||||
if err := gw.Start(); err != nil {
|
||||
return windowsHint(fmt.Errorf("failed to start gateway: %w", err))
|
||||
}
|
||||
defer func() {
|
||||
if gw.Process != nil {
|
||||
_ = gw.Process.Kill()
|
||||
_ = gw.Wait()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
|
||||
if !waitForPort(addr, 30*time.Second) {
|
||||
return windowsHint(fmt.Errorf("gateway did not start on %s", addr))
|
||||
cleanup, token, port, err := c.ensureGatewayReady(bin)
|
||||
if err != nil {
|
||||
return windowsHint(err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
printOpenclawReady(bin, token, port, firstLaunch)
|
||||
|
||||
@@ -179,6 +152,66 @@ func (c *Openclaw) Run(model string, args []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldEnsureGatewayForArgs(args []string) bool {
|
||||
return len(args) > 0 && args[0] == "tui"
|
||||
}
|
||||
|
||||
func (c *Openclaw) ensureGatewayReady(bin string) (func(), string, int, error) {
|
||||
token, port := c.gatewayInfo()
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
|
||||
// If the gateway is already running (e.g. via the daemon), restart it
|
||||
// so it picks up any config changes (model, provider, etc.).
|
||||
if portOpen(addr) {
|
||||
restart := exec.Command(bin, "daemon", "restart")
|
||||
restart.Env = openclawEnv()
|
||||
if err := restart.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: daemon restart failed: %v%s\n", ansiYellow, err, ansiReset)
|
||||
}
|
||||
if !waitForPort(addr, 10*time.Second) {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: gateway did not come back after restart%s\n", ansiYellow, ansiReset)
|
||||
}
|
||||
}
|
||||
|
||||
// If the daemon is installed but not currently listening, try to bring it
|
||||
// up before falling back to a foreground child process.
|
||||
if openclawCanInstallDaemon() && !portOpen(addr) {
|
||||
start := exec.Command(bin, "daemon", "start")
|
||||
start.Env = openclawEnv()
|
||||
if err := start.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: daemon start failed: %v%s\n", ansiYellow, err, ansiReset)
|
||||
} else if waitForPort(addr, 10*time.Second) {
|
||||
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
|
||||
return func() {}, token, port, nil
|
||||
}
|
||||
}
|
||||
|
||||
cleanup := func() {}
|
||||
|
||||
// If the gateway still isn't running, start it as a background child process.
|
||||
if !portOpen(addr) {
|
||||
gw := exec.Command(bin, "gateway", "run", "--force")
|
||||
gw.Env = openclawEnv()
|
||||
if err := gw.Start(); err != nil {
|
||||
return nil, "", 0, fmt.Errorf("failed to start gateway: %w", err)
|
||||
}
|
||||
cleanup = func() {
|
||||
if gw.Process != nil {
|
||||
_ = gw.Process.Kill()
|
||||
_ = gw.Wait()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sStarting gateway...%s\n", ansiGray, ansiReset)
|
||||
if !waitForPort(addr, 30*time.Second) {
|
||||
cleanup()
|
||||
return nil, "", 0, fmt.Errorf("gateway did not start on %s", addr)
|
||||
}
|
||||
|
||||
return cleanup, token, port, nil
|
||||
}
|
||||
|
||||
// runChannelSetupPreflight prompts users to connect a messaging channel before
|
||||
// starting the built-in gateway+TUI flow. In interactive sessions, it loops
|
||||
// until a channel is configured, unless the user chooses "Set up later".
|
||||
@@ -301,7 +334,7 @@ func (c *Openclaw) gatewayInfo() (token string, port int) {
|
||||
}
|
||||
|
||||
func printOpenclawReady(bin, token string, port int, firstLaunch bool) {
|
||||
u := fmt.Sprintf("http://localhost:%d", port)
|
||||
u := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||||
if token != "" {
|
||||
u += "/#token=" + url.QueryEscape(token)
|
||||
}
|
||||
@@ -339,9 +372,30 @@ func openclawEnv() []string {
|
||||
env = append(env, e)
|
||||
}
|
||||
}
|
||||
if _, ok := os.LookupEnv("OPENCLAW_PLUGIN_STAGE_DIR"); !ok {
|
||||
if dir := openclawPluginStageDir(); dir != "" {
|
||||
env = append(env, "OPENCLAW_PLUGIN_STAGE_DIR="+dir)
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func openclawInstallEnv() []string {
|
||||
env := openclawEnv()
|
||||
if _, ok := os.LookupEnv("OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS"); !ok {
|
||||
env = append(env, "OPENCLAW_EAGER_BUNDLED_PLUGIN_DEPS=1")
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func openclawPluginStageDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".openclaw", "plugin-runtime-deps")
|
||||
}
|
||||
|
||||
// portOpen checks if a TCP port is currently accepting connections.
|
||||
func portOpen(addr string) bool {
|
||||
conn, err := net.DialTimeout("tcp", addr, 500*time.Millisecond)
|
||||
@@ -565,6 +619,7 @@ func ensureOpenclawInstalled() (string, error) {
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling OpenClaw...\n")
|
||||
cmd := exec.Command("npm", "install", "-g", "openclaw@latest")
|
||||
cmd.Env = openclawInstallEnv()
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -594,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
|
||||
}
|
||||
@@ -648,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
|
||||
@@ -682,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
|
||||
@@ -691,13 +744,13 @@ func (c *Openclaw) Edit(models []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileutil.WriteWithBackup(configPath, data); err != nil {
|
||||
if err := fileutil.WriteWithBackup(configPath, data, "openclaw"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -738,89 +791,13 @@ func clearSessionModelOverride(primary string) {
|
||||
_ = os.WriteFile(path, out, 0o600)
|
||||
}
|
||||
|
||||
const (
|
||||
webSearchNpmPackage = "@ollama/openclaw-web-search"
|
||||
webSearchMinVersion = "0.2.1"
|
||||
)
|
||||
|
||||
// ensureWebSearchPlugin installs the openclaw-web-search extension into the
|
||||
// user-level extensions directory (~/.openclaw/extensions/) if it isn't already
|
||||
// present, or re-installs if the installed version is older than webSearchMinVersion.
|
||||
// Returns true if the extension is available.
|
||||
func ensureWebSearchPlugin() bool {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search")
|
||||
if webSearchPluginUpToDate(pluginDir) {
|
||||
return true
|
||||
}
|
||||
|
||||
npmBin, err := exec.LookPath("npm")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Download the tarball via `npm pack`, extract it flat into the plugin dir.
|
||||
pack := exec.Command(npmBin, "pack", webSearchNpmPackage, "--pack-destination", pluginDir)
|
||||
out, err := pack.Output()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: could not download web search plugin: %v%s\n", ansiYellow, err, ansiReset)
|
||||
return false
|
||||
}
|
||||
|
||||
tgzName := strings.TrimSpace(string(out))
|
||||
tgzPath := filepath.Join(pluginDir, tgzName)
|
||||
defer os.Remove(tgzPath)
|
||||
|
||||
tar := exec.Command("tar", "xzf", tgzPath, "--strip-components=1", "-C", pluginDir)
|
||||
if err := tar.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: could not extract web search plugin: %v%s\n", ansiYellow, err, ansiReset)
|
||||
return false
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s ✓ Installed Ollama web search %s\n", ansiGreen, ansiReset)
|
||||
return true
|
||||
}
|
||||
|
||||
// webSearchPluginUpToDate returns true if the plugin is installed and its
|
||||
// package.json version is >= webSearchMinVersion.
|
||||
func webSearchPluginUpToDate(pluginDir string) bool {
|
||||
data, err := os.ReadFile(filepath.Join(pluginDir, "package.json"))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var pkg struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if json.Unmarshal(data, &pkg) != nil || pkg.Version == "" {
|
||||
return false
|
||||
}
|
||||
return !versionLessThan(pkg.Version, webSearchMinVersion)
|
||||
}
|
||||
|
||||
// versionLessThan compares two semver version strings (major.minor.patch).
|
||||
// Inputs may omit the "v" prefix; it is added automatically for semver.Compare.
|
||||
func versionLessThan(a, b string) bool {
|
||||
if !strings.HasPrefix(a, "v") {
|
||||
a = "v" + a
|
||||
}
|
||||
if !strings.HasPrefix(b, "v") {
|
||||
b = "v" + b
|
||||
}
|
||||
return semver.Compare(a, b) < 0
|
||||
}
|
||||
|
||||
// registerWebSearchPlugin adds plugins.entries.openclaw-web-search to the OpenClaw
|
||||
// config so the gateway activates it on next start. Best-effort; silently returns
|
||||
// on any error.
|
||||
func registerWebSearchPlugin() {
|
||||
// configureOllamaWebSearch keeps launch-managed OpenClaw installs on the
|
||||
// bundled Ollama web_search provider. Older launch builds installed an
|
||||
// external openclaw-web-search plugin that added custom ollama_web_search and
|
||||
// ollama_web_fetch tools. Current OpenClaw versions ship Ollama web_search as
|
||||
// the bundled "ollama" plugin instead, so we migrate stale config and ensure
|
||||
// fresh installs select the bundled provider.
|
||||
func configureOllamaWebSearch() {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return
|
||||
@@ -835,6 +812,8 @@ func registerWebSearchPlugin() {
|
||||
return
|
||||
}
|
||||
|
||||
stalePluginConfigured := false
|
||||
|
||||
plugins, _ := config["plugins"].(map[string]any)
|
||||
if plugins == nil {
|
||||
plugins = make(map[string]any)
|
||||
@@ -843,68 +822,100 @@ func registerWebSearchPlugin() {
|
||||
if entries == nil {
|
||||
entries = make(map[string]any)
|
||||
}
|
||||
entries["openclaw-web-search"] = map[string]any{"enabled": true}
|
||||
plugins["entries"] = entries
|
||||
|
||||
// Pin trust so the gateway doesn't warn about untracked plugins.
|
||||
allow, _ := plugins["allow"].([]any)
|
||||
hasAllow := false
|
||||
for _, v := range allow {
|
||||
if s, ok := v.(string); ok && s == "openclaw-web-search" {
|
||||
hasAllow = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAllow {
|
||||
allow = append(allow, "openclaw-web-search")
|
||||
}
|
||||
plugins["allow"] = allow
|
||||
|
||||
// Record install provenance so the loader can verify the plugin origin.
|
||||
installs, _ := plugins["installs"].(map[string]any)
|
||||
if installs == nil {
|
||||
installs = make(map[string]any)
|
||||
}
|
||||
pluginDir := filepath.Join(home, ".openclaw", "extensions", "openclaw-web-search")
|
||||
installs["openclaw-web-search"] = map[string]any{
|
||||
"source": "npm",
|
||||
"spec": webSearchNpmPackage,
|
||||
"installPath": pluginDir,
|
||||
}
|
||||
plugins["installs"] = installs
|
||||
|
||||
config["plugins"] = plugins
|
||||
|
||||
// Add plugin tools to tools.alsoAllow so they survive the coding profile's
|
||||
// policy pipeline (which has an explicit allow list of core tools only).
|
||||
tools, _ := config["tools"].(map[string]any)
|
||||
if tools == nil {
|
||||
tools = make(map[string]any)
|
||||
}
|
||||
|
||||
alsoAllow, _ := tools["alsoAllow"].([]any)
|
||||
needed := []string{"ollama_web_search", "ollama_web_fetch"}
|
||||
have := make(map[string]bool, len(alsoAllow))
|
||||
for _, v := range alsoAllow {
|
||||
if s, ok := v.(string); ok {
|
||||
have[s] = true
|
||||
}
|
||||
}
|
||||
for _, name := range needed {
|
||||
if !have[name] {
|
||||
alsoAllow = append(alsoAllow, name)
|
||||
}
|
||||
}
|
||||
tools["alsoAllow"] = alsoAllow
|
||||
|
||||
// Disable built-in web search/fetch since our plugin replaces them.
|
||||
web, _ := tools["web"].(map[string]any)
|
||||
if web == nil {
|
||||
web = make(map[string]any)
|
||||
}
|
||||
web["search"] = map[string]any{"enabled": false}
|
||||
web["fetch"] = map[string]any{"enabled": false}
|
||||
search, _ := web["search"].(map[string]any)
|
||||
if search == nil {
|
||||
search = make(map[string]any)
|
||||
}
|
||||
fetch, _ := web["fetch"].(map[string]any)
|
||||
if fetch == nil {
|
||||
fetch = make(map[string]any)
|
||||
}
|
||||
|
||||
alsoAllow, _ := tools["alsoAllow"].([]any)
|
||||
var filteredAlsoAllow []any
|
||||
for _, v := range alsoAllow {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
filteredAlsoAllow = append(filteredAlsoAllow, v)
|
||||
continue
|
||||
}
|
||||
if s == "ollama_web_search" || s == "ollama_web_fetch" {
|
||||
stalePluginConfigured = true
|
||||
continue
|
||||
}
|
||||
filteredAlsoAllow = append(filteredAlsoAllow, v)
|
||||
}
|
||||
if len(filteredAlsoAllow) > 0 {
|
||||
tools["alsoAllow"] = filteredAlsoAllow
|
||||
} else {
|
||||
delete(tools, "alsoAllow")
|
||||
}
|
||||
|
||||
if _, ok := entries["openclaw-web-search"]; ok {
|
||||
delete(entries, "openclaw-web-search")
|
||||
stalePluginConfigured = true
|
||||
}
|
||||
ollamaEntry, _ := entries["ollama"].(map[string]any)
|
||||
if ollamaEntry == nil {
|
||||
ollamaEntry = make(map[string]any)
|
||||
}
|
||||
ollamaEntry["enabled"] = true
|
||||
entries["ollama"] = ollamaEntry
|
||||
plugins["entries"] = entries
|
||||
|
||||
if allow, ok := plugins["allow"].([]any); ok {
|
||||
var nextAllow []any
|
||||
hasOllama := false
|
||||
for _, v := range allow {
|
||||
s, ok := v.(string)
|
||||
if ok && s == "openclaw-web-search" {
|
||||
stalePluginConfigured = true
|
||||
continue
|
||||
}
|
||||
if ok && s == "ollama" {
|
||||
hasOllama = true
|
||||
}
|
||||
nextAllow = append(nextAllow, v)
|
||||
}
|
||||
if !hasOllama {
|
||||
nextAllow = append(nextAllow, "ollama")
|
||||
}
|
||||
plugins["allow"] = nextAllow
|
||||
}
|
||||
|
||||
if installs, ok := plugins["installs"].(map[string]any); ok {
|
||||
if _, exists := installs["openclaw-web-search"]; exists {
|
||||
delete(installs, "openclaw-web-search")
|
||||
stalePluginConfigured = true
|
||||
}
|
||||
if len(installs) > 0 {
|
||||
plugins["installs"] = installs
|
||||
} else {
|
||||
delete(plugins, "installs")
|
||||
}
|
||||
}
|
||||
|
||||
if stalePluginConfigured || search["provider"] == nil {
|
||||
search["provider"] = "ollama"
|
||||
}
|
||||
if stalePluginConfigured {
|
||||
fetch["enabled"] = true
|
||||
}
|
||||
search["enabled"] = true
|
||||
web["search"] = search
|
||||
if len(fetch) > 0 {
|
||||
web["fetch"] = fetch
|
||||
}
|
||||
tools["web"] = web
|
||||
config["plugins"] = plugins
|
||||
config["tools"] = tools
|
||||
|
||||
out, err := json.MarshalIndent(config, "", " ")
|
||||
@@ -916,10 +927,10 @@ func registerWebSearchPlugin() {
|
||||
|
||||
// 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,
|
||||
@@ -929,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 {
|
||||
|
||||
+507
-346
File diff suppressed because it is too large.
Load diff
+68
-22
@@ -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
|
||||
}
|
||||
@@ -163,7 +200,7 @@ func (o *OpenCode) Edit(modelList []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(statePath, stateData)
|
||||
return fileutil.WriteWithBackup(statePath, stateData, "opencode")
|
||||
}
|
||||
|
||||
func (o *OpenCode) Models() []string {
|
||||
@@ -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)
|
||||
|
||||
+15
-46
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +268,7 @@ func (p *Pi) Edit(models []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileutil.WriteWithBackup(configPath, configData); err != nil {
|
||||
if err := fileutil.WriteWithBackup(configPath, configData, "pi"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -284,13 +280,13 @@ 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 {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(settingsPath, settingsData)
|
||||
return fileutil.WriteWithBackup(settingsPath, settingsData, "pi")
|
||||
}
|
||||
|
||||
func (p *Pi) Models() []string {
|
||||
@@ -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
|
||||
|
||||
+100
-152
@@ -1,19 +1,17 @@
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -137,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)
|
||||
}
|
||||
|
||||
@@ -180,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)
|
||||
}
|
||||
@@ -202,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)
|
||||
}
|
||||
|
||||
@@ -237,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)
|
||||
}
|
||||
|
||||
@@ -265,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)
|
||||
}
|
||||
})
|
||||
@@ -300,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)
|
||||
}
|
||||
})
|
||||
@@ -330,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)
|
||||
}
|
||||
})
|
||||
@@ -359,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)
|
||||
}
|
||||
@@ -434,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)
|
||||
}
|
||||
})
|
||||
@@ -443,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)
|
||||
}
|
||||
|
||||
@@ -496,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)
|
||||
}
|
||||
|
||||
@@ -549,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)
|
||||
}
|
||||
|
||||
@@ -594,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)
|
||||
}
|
||||
|
||||
@@ -645,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)
|
||||
}
|
||||
|
||||
@@ -682,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)
|
||||
}
|
||||
|
||||
@@ -731,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)
|
||||
}
|
||||
|
||||
@@ -792,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)
|
||||
}
|
||||
|
||||
@@ -830,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)
|
||||
}
|
||||
|
||||
@@ -864,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)
|
||||
}
|
||||
|
||||
@@ -887,6 +885,62 @@ func TestPiEdit(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestPiEdit_CreatesDistinctBackupsForEachManagedFile(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/show" {
|
||||
fmt.Fprint(w, `{"capabilities":[],"model_info":{}}`)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
|
||||
pi := &Pi{}
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".pi", "agent")
|
||||
modelsPath := filepath.Join(configDir, "models.json")
|
||||
settingsPath := filepath.Join(configDir, "settings.json")
|
||||
backupDir := fileutil.BackupDir()
|
||||
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
modelsOriginal := fmt.Sprintf(`{"marker":"models-%d","providers":{"ollama":{"models":[]}}}`, os.Getpid())
|
||||
settingsOriginal := fmt.Sprintf(`{"marker":"settings-%d","defaultProvider":"other","defaultModel":"old"}`, os.Getpid())
|
||||
if err := os.WriteFile(modelsPath, []byte(modelsOriginal), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(settingsPath, []byte(settingsOriginal), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := pi.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatalf("Edit() error = %v", err)
|
||||
}
|
||||
|
||||
assertBackupMatches := func(pattern, want string) {
|
||||
t.Helper()
|
||||
backups, err := filepath.Glob(filepath.Join(backupDir, pattern))
|
||||
if err != nil {
|
||||
t.Fatalf("glob %q failed: %v", pattern, err)
|
||||
}
|
||||
for _, backup := range backups {
|
||||
data, err := os.ReadFile(backup)
|
||||
if err == nil && string(data) == want {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("backup matching %q with expected content not found", pattern)
|
||||
}
|
||||
|
||||
assertBackupMatches(filepath.Join("pi", "models.json.*"), modelsOriginal)
|
||||
assertBackupMatches(filepath.Join("pi", "settings.json.*"), settingsOriginal)
|
||||
}
|
||||
|
||||
func TestPiModels(t *testing.T) {
|
||||
pi := &Pi{}
|
||||
|
||||
@@ -1030,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"])
|
||||
@@ -1057,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" {
|
||||
@@ -1081,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"])
|
||||
@@ -1121,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" {
|
||||
@@ -1147,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"])
|
||||
@@ -1165,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"])
|
||||
@@ -1215,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")
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
)
|
||||
|
||||
// Poolside implements Runner for Poolside's CLI.
|
||||
type Poolside struct{}
|
||||
|
||||
var poolsideGOOS = runtime.GOOS
|
||||
|
||||
func (p *Poolside) String() string { return "Pool" }
|
||||
|
||||
func poolsideUnsupportedError() error {
|
||||
return fmt.Errorf("Warning: Poolside is not currently supported on Windows")
|
||||
}
|
||||
|
||||
func (p *Poolside) args(model string, extra []string) []string {
|
||||
var args []string
|
||||
if model != "" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
args = append(args, extra...)
|
||||
return args
|
||||
}
|
||||
|
||||
func (p *Poolside) Run(model string, _ []LaunchModel, args []string) error {
|
||||
if poolsideGOOS == "windows" {
|
||||
return poolsideUnsupportedError()
|
||||
}
|
||||
|
||||
bin, err := exec.LookPath("pool")
|
||||
if err != nil {
|
||||
return fmt.Errorf("pool is not installed")
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, p.args(model, args)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = append(os.Environ(),
|
||||
"POOLSIDE_STANDALONE_BASE_URL="+envconfig.Host().String()+"/v1",
|
||||
"POOLSIDE_API_KEY=ollama",
|
||||
)
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPoolsideArgs(t *testing.T) {
|
||||
p := &Poolside{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
extra []string
|
||||
want []string
|
||||
}{
|
||||
{name: "with model", model: "qwen3.5", want: []string{"-m", "qwen3.5"}},
|
||||
{name: "without model", extra: []string{"session"}, want: []string{"session"}},
|
||||
{name: "with model and extra args", model: "llama3.2", extra: []string{"--foo", "bar"}, want: []string{"-m", "llama3.2", "--foo", "bar"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := p.args(tt.model, tt.extra)
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Fatalf("args(%q, %v) = %v, want %v", tt.model, tt.extra, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsideRunSetsOllamaEnv(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell fake binary")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
logPath := filepath.Join(tmpDir, "pool.log")
|
||||
poolPath := filepath.Join(tmpDir, "pool")
|
||||
script := "#!/bin/sh\n" +
|
||||
"printf 'base=%s\\nkey=%s\\nargs=%s\\n' \"$POOLSIDE_STANDALONE_BASE_URL\" \"$POOLSIDE_API_KEY\" \"$*\" > \"" + logPath + "\"\n"
|
||||
if err := os.WriteFile(poolPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake pool binary: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", tmpDir)
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||
|
||||
p := &Poolside{}
|
||||
if err := p.Run("qwen3.5", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read pool log: %v", err)
|
||||
}
|
||||
|
||||
got := string(data)
|
||||
if !strings.Contains(got, "base=http://127.0.0.1:11434/v1") {
|
||||
t.Fatalf("expected Poolside base URL override in log, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "key=ollama") {
|
||||
t.Fatalf("expected Poolside API key override in log, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "args=-m qwen3.5 session") {
|
||||
t.Fatalf("expected model and extra args in log, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoolsideRunWindowsUnsupported(t *testing.T) {
|
||||
prev := poolsideGOOS
|
||||
poolsideGOOS = "windows"
|
||||
t.Cleanup(func() { poolsideGOOS = prev })
|
||||
|
||||
p := &Poolside{}
|
||||
err := p.Run("kimi-k2.6:cloud", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected Windows unsupported error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not currently supported on Windows") {
|
||||
t.Fatalf("expected Windows warning, got %v", err)
|
||||
}
|
||||
}
|
||||
+84
-1
@@ -33,7 +33,7 @@ type IntegrationInfo struct {
|
||||
Description string
|
||||
}
|
||||
|
||||
var launcherIntegrationOrder = []string{"openclaw", "claude", "opencode", "hermes", "codex", "droid", "pi"}
|
||||
var launcherIntegrationOrder = []string{"claude", "codex-app", "hermes", "openclaw", "opencode", "codex", "copilot", "droid", "pi", "pool"}
|
||||
|
||||
var integrationSpecs = []*IntegrationSpec{
|
||||
{
|
||||
@@ -48,6 +48,19 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
URL: "https://code.claude.com/docs/en/quickstart",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "claude-desktop",
|
||||
Runner: &ClaudeDesktop{},
|
||||
Aliases: []string{"claude-app"},
|
||||
Description: "Claude Desktop with Ollama Cloud",
|
||||
Hidden: true,
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
return claudeDesktopInstalled()
|
||||
},
|
||||
URL: "https://claude.com/download",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cline",
|
||||
Runner: &Cline{},
|
||||
@@ -74,6 +87,48 @@ 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{},
|
||||
Description: "Moonshot's coding agent for terminal and IDEs",
|
||||
Hidden: true,
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := exec.LookPath("kimi")
|
||||
return err == nil
|
||||
},
|
||||
EnsureInstalled: func() error {
|
||||
_, err := ensureKimiInstalled()
|
||||
return err
|
||||
},
|
||||
URL: "https://moonshotai.github.io/kimi-cli/en/guides/getting-started.html",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "copilot",
|
||||
Runner: &Copilot{},
|
||||
Aliases: []string{"copilot-cli"},
|
||||
Description: "GitHub's AI coding agent for the terminal",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := (&Copilot{}).findPath()
|
||||
return err == nil
|
||||
},
|
||||
URL: "https://github.com/features/copilot/cli/",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "droid",
|
||||
Runner: &Droid{},
|
||||
@@ -136,6 +191,18 @@ var integrationSpecs = []*IntegrationSpec{
|
||||
Command: []string{"npm", "install", "-g", "@mariozechner/pi-coding-agent@latest"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pool",
|
||||
Runner: &Poolside{},
|
||||
Description: "Poolside's software agent for enterprise development",
|
||||
Install: IntegrationInstallSpec{
|
||||
CheckInstalled: func() bool {
|
||||
_, err := exec.LookPath("pool")
|
||||
return err == nil
|
||||
},
|
||||
URL: "https://github.com/poolsideai/pool",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "hermes",
|
||||
Runner: &Hermes{},
|
||||
@@ -255,6 +322,12 @@ func ListVisibleIntegrationSpecs() []IntegrationSpec {
|
||||
if spec.Hidden {
|
||||
continue
|
||||
}
|
||||
if supported, ok := spec.Runner.(SupportedIntegration); ok && supported.Supported() != nil {
|
||||
continue
|
||||
}
|
||||
if spec.Name == "pool" && poolsideGOOS == "windows" {
|
||||
continue
|
||||
}
|
||||
visible = append(visible, *spec)
|
||||
}
|
||||
|
||||
@@ -369,6 +442,16 @@ func EnsureIntegrationInstalled(name string, runner Runner) error {
|
||||
return fmt.Errorf("%s is not installed", runner)
|
||||
}
|
||||
|
||||
if supported, ok := runner.(SupportedIntegration); ok {
|
||||
if err := supported.Supported(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if integration.spec.Name == "pool" && poolsideGOOS == "windows" {
|
||||
return poolsideUnsupportedError()
|
||||
}
|
||||
|
||||
if integration.installed {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,10 +45,30 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
||||
return filepath.Join(home, ".pi", "agent", "models.json")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "pool",
|
||||
binary: "pool",
|
||||
runner: &Poolside{},
|
||||
checkPath: func(home string) string {
|
||||
return filepath.Join(home, ".poolside", "config")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "kimi",
|
||||
binary: "kimi",
|
||||
runner: &Kimi{},
|
||||
checkPath: func(home string) string {
|
||||
return filepath.Join(home, ".kimi", "config.toml")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.name == "pool" && poolsideGOOS == "windows" {
|
||||
t.Skip("Poolside is intentionally unsupported on Windows")
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
setTestHome(t, home)
|
||||
|
||||
@@ -57,10 +77,14 @@ func TestEditorRunsDoNotRewriteConfig(t *testing.T) {
|
||||
if tt.name == "pi" {
|
||||
writeFakeBinary(t, binDir, "npm")
|
||||
}
|
||||
if tt.name == "kimi" {
|
||||
writeFakeBinary(t, binDir, "curl")
|
||||
writeFakeBinary(t, binDir, "bash")
|
||||
}
|
||||
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) {
|
||||
|
||||
@@ -35,22 +35,38 @@ type ConfirmOptions struct {
|
||||
|
||||
// SingleSelector is a function type for single item selection.
|
||||
// current is the name of the previously selected item to highlight; empty means no pre-selection.
|
||||
type SingleSelector func(title string, items []ModelItem, current string) (string, error)
|
||||
type SingleSelector func(title string, items []SelectionItem, current string) (string, error)
|
||||
|
||||
// SingleSelectorWithUpdates is a single item selector that can receive refreshed item state while open.
|
||||
type SingleSelectorWithUpdates func(title string, items []SelectionItem, current string, updates <-chan []SelectionItem) (string, error)
|
||||
|
||||
// MultiSelector is a function type for multi item selection.
|
||||
type MultiSelector func(title string, items []ModelItem, preChecked []string) ([]string, error)
|
||||
type MultiSelector func(title string, items []SelectionItem, preChecked []string) ([]string, error)
|
||||
|
||||
// MultiSelectorWithUpdates is a multi item selector that can receive refreshed item state while open.
|
||||
type MultiSelectorWithUpdates func(title string, items []SelectionItem, preChecked []string, updates <-chan []SelectionItem) ([]string, error)
|
||||
|
||||
// DefaultSingleSelector is the default single-select implementation.
|
||||
var DefaultSingleSelector SingleSelector
|
||||
|
||||
// DefaultSingleSelectorWithUpdates is the default single-select implementation with live updates.
|
||||
var DefaultSingleSelectorWithUpdates SingleSelectorWithUpdates
|
||||
|
||||
// DefaultMultiSelector is the default multi-select implementation.
|
||||
var DefaultMultiSelector MultiSelector
|
||||
|
||||
// DefaultMultiSelectorWithUpdates is the default multi-select implementation with live updates.
|
||||
var DefaultMultiSelectorWithUpdates MultiSelectorWithUpdates
|
||||
|
||||
// DefaultSignIn provides a TUI-based sign-in flow.
|
||||
// When set, ensureAuth uses it instead of plain text prompts.
|
||||
// Returns the signed-in username or an error.
|
||||
var DefaultSignIn func(modelName, signInURL string) (string, error)
|
||||
|
||||
// DefaultUpgrade provides a TUI-based upgrade flow.
|
||||
// Returns the updated plan or an error.
|
||||
var DefaultUpgrade func(modelName, requiredPlan string) (string, error)
|
||||
|
||||
type launchConfirmPolicy struct {
|
||||
yes bool
|
||||
requireYesMessage bool
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -273,7 +273,7 @@ func (v *VSCode) Edit(models []string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fileutil.WriteWithBackup(clmPath, data); err != nil {
|
||||
if err := fileutil.WriteWithBackup(clmPath, data, "vscode"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -350,7 +350,7 @@ func (v *VSCode) updateSettings() {
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = fileutil.WriteWithBackup(settingsPath, updated)
|
||||
_ = fileutil.WriteWithBackup(settingsPath, updated, "vscode")
|
||||
}
|
||||
|
||||
func (v *VSCode) statePath() string {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
)
|
||||
|
||||
func TestVSCodeIntegration(t *testing.T) {
|
||||
@@ -112,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)
|
||||
}
|
||||
|
||||
@@ -133,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)
|
||||
}
|
||||
|
||||
@@ -156,6 +157,52 @@ func TestVSCodeEditCleansUpOldSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVSCodeEdit_CreatesDistinctBackupsForManagedFiles(t *testing.T) {
|
||||
v := &VSCode{}
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
|
||||
clmPath := testVSCodePath(t, tmpDir, "chatLanguageModels.json")
|
||||
settingsPath := testVSCodePath(t, tmpDir, "settings.json")
|
||||
backupDir := fileutil.BackupDir()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(clmPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
clmOriginal := `[{"vendor":"ollama","name":"Ollama","url":"http://old:11434"}]`
|
||||
settingsOriginal := `{"github.copilot.chat.byok.ollamaEndpoint":"http://old:11434","ollama.launch.configured":true,"editor.fontSize":14}`
|
||||
if err := os.WriteFile(clmPath, []byte(clmOriginal), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(settingsPath, []byte(settingsOriginal), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := v.Edit(testLaunchModels("llama3.2")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertBackupMatches := func(pattern, want string) {
|
||||
t.Helper()
|
||||
backups, err := filepath.Glob(filepath.Join(backupDir, pattern))
|
||||
if err != nil {
|
||||
t.Fatalf("glob %q failed: %v", pattern, err)
|
||||
}
|
||||
for _, backup := range backups {
|
||||
data, err := os.ReadFile(backup)
|
||||
if err == nil && string(data) == want {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("backup matching %q with expected content not found", pattern)
|
||||
}
|
||||
|
||||
assertBackupMatches(filepath.Join("vscode", "chatLanguageModels.json.*"), clmOriginal)
|
||||
assertBackupMatches(filepath.Join("vscode", "settings.json.*"), settingsOriginal)
|
||||
}
|
||||
|
||||
func TestVSCodePaths(t *testing.T) {
|
||||
v := &VSCode{}
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
+122
-10
@@ -35,8 +35,7 @@ var (
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "252"})
|
||||
|
||||
selectorDefaultTagStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}).
|
||||
Italic(true)
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"})
|
||||
|
||||
selectorHelpStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"})
|
||||
@@ -58,16 +57,39 @@ const maxSelectorItems = 10
|
||||
var ErrCancelled = launch.ErrCancelled
|
||||
|
||||
type SelectItem struct {
|
||||
Name string
|
||||
Description string
|
||||
Recommended bool
|
||||
Name string
|
||||
Description string
|
||||
Recommended bool
|
||||
AvailabilityBadge string
|
||||
}
|
||||
|
||||
// ConvertItems converts launch.ModelItem slice to SelectItem slice.
|
||||
func ConvertItems(items []launch.ModelItem) []SelectItem {
|
||||
type selectorItemsUpdatedMsg struct {
|
||||
items []SelectItem
|
||||
}
|
||||
|
||||
func waitForSelectorItems(updates <-chan []SelectItem) tea.Cmd {
|
||||
if updates == nil {
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
items, ok := <-updates
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return selectorItemsUpdatedMsg{items: items}
|
||||
}
|
||||
}
|
||||
|
||||
// ConvertItems converts launch.SelectionItem slice to SelectItem slice.
|
||||
func ConvertItems(items []launch.SelectionItem) []SelectItem {
|
||||
out := make([]SelectItem, len(items))
|
||||
for i, item := range items {
|
||||
out[i] = SelectItem{Name: item.Name, Description: item.Description, Recommended: item.Recommended}
|
||||
out[i] = SelectItem{
|
||||
Name: item.Name,
|
||||
Description: item.Description,
|
||||
Recommended: item.Recommended,
|
||||
AvailabilityBadge: item.AvailabilityBadge,
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -91,6 +113,7 @@ func ReorderItems(items []SelectItem) []SelectItem {
|
||||
type selectorModel struct {
|
||||
title string
|
||||
items []SelectItem
|
||||
updates <-chan []SelectItem
|
||||
filter string
|
||||
cursor int
|
||||
scrollOffset int
|
||||
@@ -110,6 +133,33 @@ func selectorModelWithCurrent(title string, items []SelectItem, current string)
|
||||
return m
|
||||
}
|
||||
|
||||
func currentItemName(items []SelectItem, cursor int) string {
|
||||
if cursor < 0 || cursor >= len(items) {
|
||||
return ""
|
||||
}
|
||||
return items[cursor].Name
|
||||
}
|
||||
|
||||
func cursorForItemName(items []SelectItem, name string, fallback int) int {
|
||||
if len(items) == 0 {
|
||||
return 0
|
||||
}
|
||||
if name != "" {
|
||||
for i, item := range items {
|
||||
if item.Name == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
}
|
||||
if fallback < 0 {
|
||||
return 0
|
||||
}
|
||||
if fallback >= len(items) {
|
||||
return len(items) - 1
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (m selectorModel) filteredItems() []SelectItem {
|
||||
if m.filter == "" {
|
||||
return m.items
|
||||
@@ -125,7 +175,7 @@ func (m selectorModel) filteredItems() []SelectItem {
|
||||
}
|
||||
|
||||
func (m selectorModel) Init() tea.Cmd {
|
||||
return nil
|
||||
return waitForSelectorItems(m.updates)
|
||||
}
|
||||
|
||||
// otherStart returns the index of the first non-recommended item in the filtered list.
|
||||
@@ -235,6 +285,13 @@ func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case selectorItemsUpdatedMsg:
|
||||
current := currentItemName(m.filteredItems(), m.cursor)
|
||||
m.items = msg.items
|
||||
m.cursor = cursorForItemName(m.filteredItems(), current, m.cursor)
|
||||
m.updateScroll(m.otherStart())
|
||||
return m, waitForSelectorItems(m.updates)
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
@@ -260,9 +317,17 @@ func (m selectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func cursorItemSuffix(item SelectItem) string {
|
||||
if item.AvailabilityBadge == "" {
|
||||
return ""
|
||||
}
|
||||
return " " + selectorDefaultTagStyle.Render("("+item.AvailabilityBadge+")")
|
||||
}
|
||||
|
||||
func (m selectorModel) renderItem(s *strings.Builder, item SelectItem, idx int) {
|
||||
if idx == m.cursor {
|
||||
s.WriteString(selectorSelectedItemStyle.Render("▸ " + item.Name))
|
||||
s.WriteString(cursorItemSuffix(item))
|
||||
} else {
|
||||
s.WriteString(selectorItemStyle.Render(item.Name))
|
||||
}
|
||||
@@ -402,11 +467,16 @@ func cursorForCurrent(items []SelectItem, current string) int {
|
||||
}
|
||||
|
||||
func SelectSingle(title string, items []SelectItem, current string) (string, error) {
|
||||
return SelectSingleWithUpdates(title, items, current, nil)
|
||||
}
|
||||
|
||||
func SelectSingleWithUpdates(title string, items []SelectItem, current string, updates <-chan []SelectItem) (string, error) {
|
||||
if len(items) == 0 {
|
||||
return "", fmt.Errorf("no items to select from")
|
||||
}
|
||||
|
||||
m := selectorModelWithCurrent(title, items, current)
|
||||
m.updates = updates
|
||||
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
@@ -426,6 +496,7 @@ func SelectSingle(title string, items []SelectItem, current string) (string, err
|
||||
type multiSelectorModel struct {
|
||||
title string
|
||||
items []SelectItem
|
||||
updates <-chan []SelectItem
|
||||
itemIndex map[string]int
|
||||
filter string
|
||||
cursor int
|
||||
@@ -475,6 +546,36 @@ func newMultiSelectorModel(title string, items []SelectItem, preChecked []string
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *multiSelectorModel) rebuildItemIndex() {
|
||||
m.itemIndex = make(map[string]int, len(m.items))
|
||||
for i, item := range m.items {
|
||||
m.itemIndex[item.Name] = i
|
||||
}
|
||||
}
|
||||
|
||||
func (m *multiSelectorModel) replaceItems(items []SelectItem) {
|
||||
current := currentItemName(m.filteredItems(), m.cursor)
|
||||
checkedNames := make([]string, 0, len(m.checkOrder))
|
||||
for _, idx := range m.checkOrder {
|
||||
if idx >= 0 && idx < len(m.items) {
|
||||
checkedNames = append(checkedNames, m.items[idx].Name)
|
||||
}
|
||||
}
|
||||
|
||||
m.items = items
|
||||
m.rebuildItemIndex()
|
||||
m.checked = make(map[int]bool, len(checkedNames))
|
||||
m.checkOrder = nil
|
||||
for _, name := range checkedNames {
|
||||
if idx, ok := m.itemIndex[name]; ok {
|
||||
m.checked[idx] = true
|
||||
m.checkOrder = append(m.checkOrder, idx)
|
||||
}
|
||||
}
|
||||
m.cursor = cursorForItemName(m.filteredItems(), current, m.cursor)
|
||||
m.updateScroll(m.otherStart())
|
||||
}
|
||||
|
||||
func (m multiSelectorModel) filteredItems() []SelectItem {
|
||||
if m.filter == "" {
|
||||
return m.items
|
||||
@@ -590,7 +691,7 @@ func (m multiSelectorModel) selectedCount() int {
|
||||
}
|
||||
|
||||
func (m multiSelectorModel) Init() tea.Cmd {
|
||||
return nil
|
||||
return waitForSelectorItems(m.updates)
|
||||
}
|
||||
|
||||
func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -603,6 +704,10 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case selectorItemsUpdatedMsg:
|
||||
m.replaceItems(msg.items)
|
||||
return m, waitForSelectorItems(m.updates)
|
||||
|
||||
case tea.KeyMsg:
|
||||
filtered := m.filteredItems()
|
||||
|
||||
@@ -689,6 +794,7 @@ func (m multiSelectorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
func (m multiSelectorModel) renderSingleItem(s *strings.Builder, item SelectItem, idx int) {
|
||||
if idx == m.cursor {
|
||||
s.WriteString(selectorSelectedItemStyle.Render("▸ " + item.Name))
|
||||
s.WriteString(cursorItemSuffix(item))
|
||||
} else {
|
||||
s.WriteString(selectorItemStyle.Render(item.Name))
|
||||
}
|
||||
@@ -716,6 +822,7 @@ func (m multiSelectorModel) renderMultiItem(s *strings.Builder, item SelectItem,
|
||||
|
||||
if idx == m.cursor {
|
||||
s.WriteString(selectorSelectedItemStyle.Render("▸ " + check + item.Name))
|
||||
s.WriteString(cursorItemSuffix(item))
|
||||
} else {
|
||||
s.WriteString(selectorItemStyle.Render(check + item.Name))
|
||||
}
|
||||
@@ -841,11 +948,16 @@ func (m multiSelectorModel) View() string {
|
||||
}
|
||||
|
||||
func SelectMultiple(title string, items []SelectItem, preChecked []string) ([]string, error) {
|
||||
return SelectMultipleWithUpdates(title, items, preChecked, nil)
|
||||
}
|
||||
|
||||
func SelectMultipleWithUpdates(title string, items []SelectItem, preChecked []string, updates <-chan []SelectItem) ([]string, error) {
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("no items to select from")
|
||||
}
|
||||
|
||||
m := newMultiSelectorModel(title, items, preChecked)
|
||||
m.updates = updates
|
||||
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
|
||||
@@ -311,6 +311,91 @@ func TestRenderContent_SelectedItemIndicator(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContent_AvailabilityBadgeOnlyOnCursor(t *testing.T) {
|
||||
m := selectorModel{
|
||||
title: "Pick:",
|
||||
items: []SelectItem{
|
||||
{Name: "kimi-k2.6:cloud", AvailabilityBadge: "Upgrade required"},
|
||||
{Name: "qwen3.5:cloud", AvailabilityBadge: "Sign in required"},
|
||||
{Name: "glm-5:cloud", AvailabilityBadge: "Included"},
|
||||
},
|
||||
cursor: 0,
|
||||
}
|
||||
content := m.renderContent()
|
||||
|
||||
if !strings.Contains(content, "(Upgrade required)") {
|
||||
t.Fatalf("cursor badge missing:\n%s", content)
|
||||
}
|
||||
if strings.Contains(content, "(Sign in required)") {
|
||||
t.Fatalf("non-cursor badge should not render:\n%s", content)
|
||||
}
|
||||
if strings.Contains(content, "Included") {
|
||||
t.Fatalf("included badge should not render:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectorModel_ItemsUpdatedPreservesCursorAndRendersBadge(t *testing.T) {
|
||||
m := selectorModelWithCurrent("Pick:", []SelectItem{
|
||||
{Name: "kimi-k2.6:cloud", Recommended: true},
|
||||
{Name: "llama3.2"},
|
||||
}, "kimi-k2.6:cloud")
|
||||
|
||||
updated, _ := m.Update(selectorItemsUpdatedMsg{items: []SelectItem{
|
||||
{Name: "kimi-k2.6:cloud", Recommended: true, AvailabilityBadge: "Upgrade required"},
|
||||
{Name: "llama3.2"},
|
||||
}})
|
||||
fm := updated.(selectorModel)
|
||||
if fm.cursor != 0 {
|
||||
t.Fatalf("cursor = %d, want 0", fm.cursor)
|
||||
}
|
||||
content := fm.renderContent()
|
||||
if !strings.Contains(content, "(Upgrade required)") {
|
||||
t.Fatalf("updated badge missing:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSelector_AvailabilityBadgePreservesDefaultSuffix(t *testing.T) {
|
||||
m := newMultiSelectorModel("Pick:", []SelectItem{
|
||||
{Name: "kimi-k2.6:cloud", AvailabilityBadge: "Upgrade required"},
|
||||
{Name: "qwen3.5:cloud"},
|
||||
}, []string{"kimi-k2.6:cloud"})
|
||||
m.multi = true
|
||||
m.cursor = 0
|
||||
|
||||
content := m.View()
|
||||
if !strings.Contains(content, "(Upgrade required)") {
|
||||
t.Fatalf("cursor badge missing:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "(default)") {
|
||||
t.Fatalf("default suffix missing:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSelector_ItemsUpdatedPreservesCheckedStateAndRendersBadge(t *testing.T) {
|
||||
m := newMultiSelectorModel("Pick:", []SelectItem{
|
||||
{Name: "kimi-k2.6:cloud", Recommended: true},
|
||||
{Name: "llama3.2"},
|
||||
}, []string{"kimi-k2.6:cloud"})
|
||||
m.multi = true
|
||||
|
||||
updated, _ := m.Update(selectorItemsUpdatedMsg{items: []SelectItem{
|
||||
{Name: "kimi-k2.6:cloud", Recommended: true, AvailabilityBadge: "Upgrade required"},
|
||||
{Name: "llama3.2"},
|
||||
}})
|
||||
fm := updated.(multiSelectorModel)
|
||||
idx := fm.itemIndex["kimi-k2.6:cloud"]
|
||||
if !fm.checked[idx] {
|
||||
t.Fatalf("checked state was not preserved: %#v", fm.checked)
|
||||
}
|
||||
content := fm.View()
|
||||
if !strings.Contains(content, "(Upgrade required)") {
|
||||
t.Fatalf("updated badge missing:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "(default)") {
|
||||
t.Fatalf("default suffix missing after update:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderContent_Description(t *testing.T) {
|
||||
m := selectorModel{
|
||||
title: "Pick:",
|
||||
|
||||
+195
-1
@@ -19,6 +19,14 @@ type signInCheckMsg struct {
|
||||
userName string
|
||||
}
|
||||
|
||||
type upgradeTickMsg struct{}
|
||||
|
||||
type upgradeCheckMsg struct {
|
||||
upgraded bool
|
||||
plan string
|
||||
err error
|
||||
}
|
||||
|
||||
type signInModel struct {
|
||||
modelName string
|
||||
signInURL string
|
||||
@@ -28,6 +36,18 @@ type signInModel struct {
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
type upgradeModel struct {
|
||||
modelName string
|
||||
requiredPlan string
|
||||
spinner int
|
||||
width int
|
||||
openNow bool
|
||||
polling bool
|
||||
plan string
|
||||
cancelled bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (m signInModel) Init() tea.Cmd {
|
||||
return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return signInTickMsg{}
|
||||
@@ -82,6 +102,85 @@ func (m signInModel) View() string {
|
||||
return renderSignIn(m.modelName, m.signInURL, m.spinner, m.width)
|
||||
}
|
||||
|
||||
func (m upgradeModel) Init() tea.Cmd {
|
||||
if m.polling {
|
||||
return upgradeTickCmd()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m upgradeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
wasSet := m.width > 0
|
||||
m.width = msg.Width
|
||||
if wasSet {
|
||||
return m, tea.EnterAltScreen
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC, tea.KeyEsc:
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyLeft:
|
||||
if !m.polling {
|
||||
m.openNow = true
|
||||
}
|
||||
case tea.KeyRight:
|
||||
if !m.polling {
|
||||
m.openNow = false
|
||||
}
|
||||
case tea.KeyEnter:
|
||||
if !m.polling {
|
||||
if !m.openNow {
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
launch.OpenBrowser(launch.DefaultUpgradeURL)
|
||||
m.polling = true
|
||||
return m, upgradeTickCmd()
|
||||
}
|
||||
}
|
||||
|
||||
case upgradeTickMsg:
|
||||
if !m.polling {
|
||||
return m, nil
|
||||
}
|
||||
m.spinner++
|
||||
if m.spinner%5 == 0 {
|
||||
return m, tea.Batch(
|
||||
upgradeTickCmd(),
|
||||
checkUpgrade(m.requiredPlan),
|
||||
)
|
||||
}
|
||||
return m, upgradeTickCmd()
|
||||
|
||||
case upgradeCheckMsg:
|
||||
if msg.err != nil {
|
||||
m.err = msg.err
|
||||
return m, tea.Quit
|
||||
}
|
||||
if msg.upgraded {
|
||||
m.plan = msg.plan
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m upgradeModel) View() string {
|
||||
if m.plan != "" {
|
||||
return ""
|
||||
}
|
||||
if m.err != nil {
|
||||
return ""
|
||||
}
|
||||
return renderUpgrade(m.modelName, m.spinner, m.width, m.polling, m.openNow)
|
||||
}
|
||||
|
||||
func renderSignIn(modelName, signInURL string, spinner, width int) string {
|
||||
spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
frame := spinnerFrames[spinner%len(spinnerFrames)]
|
||||
@@ -110,18 +209,88 @@ func renderSignIn(modelName, signInURL string, spinner, width int) string {
|
||||
return lipgloss.NewStyle().PaddingLeft(2).Render(s.String())
|
||||
}
|
||||
|
||||
func upgradeTickCmd() tea.Cmd {
|
||||
return tea.Tick(200*time.Millisecond, func(t time.Time) tea.Msg {
|
||||
return upgradeTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
func renderUpgrade(modelName string, spinner, width int, polling, openNow bool) string {
|
||||
spinnerFrames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
frame := spinnerFrames[spinner%len(spinnerFrames)]
|
||||
|
||||
urlColor := lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("117"))
|
||||
urlWrap := lipgloss.NewStyle().PaddingLeft(2)
|
||||
if width > 4 {
|
||||
urlWrap = urlWrap.Width(width - 4)
|
||||
}
|
||||
|
||||
var s strings.Builder
|
||||
|
||||
fmt.Fprintf(&s, "To use %s, upgrade your Ollama plan.\n\n", selectorSelectedItemStyle.Render(modelName))
|
||||
|
||||
s.WriteString("Navigate to:\n")
|
||||
s.WriteString(urlWrap.Render(urlColor.Render(launch.DefaultUpgradeURL)))
|
||||
s.WriteString("\n\n")
|
||||
|
||||
if !polling {
|
||||
var yesBtn, noBtn string
|
||||
if openNow {
|
||||
yesBtn = confirmActiveStyle.Render(" Yes ")
|
||||
noBtn = confirmInactiveStyle.Render(" No ")
|
||||
} else {
|
||||
yesBtn = confirmInactiveStyle.Render(" Yes ")
|
||||
noBtn = confirmActiveStyle.Render(" No ")
|
||||
}
|
||||
|
||||
s.WriteString("Open now?\n")
|
||||
s.WriteString(" " + yesBtn + " " + noBtn)
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(selectorHelpStyle.Render("←/→ navigate • enter confirm • esc cancel"))
|
||||
} else {
|
||||
s.WriteString(lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{Light: "242", Dark: "246"}).Render(
|
||||
frame + " Waiting for upgrade to complete..."))
|
||||
s.WriteString("\n\n")
|
||||
s.WriteString(selectorHelpStyle.Render("esc cancel"))
|
||||
}
|
||||
|
||||
return lipgloss.NewStyle().PaddingLeft(2).Render(s.String())
|
||||
}
|
||||
|
||||
func checkSignIn() tea.Msg {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return signInCheckMsg{signedIn: false}
|
||||
}
|
||||
user, err := client.Whoami(context.Background())
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
user, err := client.Whoami(ctx)
|
||||
if err == nil && user != nil && user.Name != "" {
|
||||
return signInCheckMsg{signedIn: true, userName: user.Name}
|
||||
}
|
||||
return signInCheckMsg{signedIn: false}
|
||||
}
|
||||
|
||||
func checkUpgrade(requiredPlan string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return upgradeCheckMsg{err: launch.ErrPlanVerificationUnavailable}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
user, err := client.Whoami(ctx)
|
||||
if err != nil {
|
||||
return upgradeCheckMsg{err: launch.ErrPlanVerificationUnavailable}
|
||||
}
|
||||
if err == nil && user != nil && user.Name != "" && launch.PlanSatisfies(user.Plan, requiredPlan) {
|
||||
return upgradeCheckMsg{upgraded: true, plan: user.Plan}
|
||||
}
|
||||
return upgradeCheckMsg{upgraded: false}
|
||||
}
|
||||
}
|
||||
|
||||
// RunSignIn shows a bubbletea sign-in dialog and polls until the user signs in or cancels.
|
||||
func RunSignIn(modelName, signInURL string) (string, error) {
|
||||
launch.OpenBrowser(signInURL)
|
||||
@@ -144,3 +313,28 @@ func RunSignIn(modelName, signInURL string) (string, error) {
|
||||
|
||||
return fm.userName, nil
|
||||
}
|
||||
|
||||
// RunUpgrade shows a bubbletea upgrade dialog and polls until the user's plan is updated or cancelled.
|
||||
func RunUpgrade(modelName, requiredPlan string) (string, error) {
|
||||
m := upgradeModel{
|
||||
modelName: modelName,
|
||||
requiredPlan: requiredPlan,
|
||||
openNow: true,
|
||||
}
|
||||
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error running upgrade: %w", err)
|
||||
}
|
||||
|
||||
fm := finalModel.(upgradeModel)
|
||||
if fm.cancelled {
|
||||
return "", ErrCancelled
|
||||
}
|
||||
if fm.err != nil {
|
||||
return "", fm.err
|
||||
}
|
||||
|
||||
return fm.plan, nil
|
||||
}
|
||||
+59
-1
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/ollama/ollama/cmd/launch"
|
||||
)
|
||||
|
||||
func TestRenderSignIn_ContainsModelName(t *testing.T) {
|
||||
@@ -25,7 +26,6 @@ func TestRenderSignIn_ContainsURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func TestRenderSignIn_ContainsSpinner(t *testing.T) {
|
||||
got := renderSignIn("test:cloud", "https://example.com", 0, 80)
|
||||
if !strings.Contains(got, "Waiting for sign in to complete") {
|
||||
@@ -51,6 +51,35 @@ func TestRenderSignIn_ContainsEscHelp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderUpgrade_AsksBeforeOpening(t *testing.T) {
|
||||
got := renderUpgrade("kimi-k2.6:cloud", 0, 80, false, true)
|
||||
if !strings.Contains(got, "kimi-k2.6:cloud") {
|
||||
t.Error("should contain model name")
|
||||
}
|
||||
if !strings.Contains(got, launch.DefaultUpgradeURL) {
|
||||
t.Error("should contain upgrade URL")
|
||||
}
|
||||
if !strings.Contains(got, "Open now?") {
|
||||
t.Error("should ask before opening")
|
||||
}
|
||||
if !strings.Contains(got, "Yes") || !strings.Contains(got, "No") {
|
||||
t.Error("should show yes/no selector")
|
||||
}
|
||||
if strings.Contains(got, "Waiting for upgrade to complete") {
|
||||
t.Error("should not start waiting before open choice is confirmed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderUpgrade_PollingShowsWaiting(t *testing.T) {
|
||||
got := renderUpgrade("kimi-k2.6:cloud", 0, 80, true, true)
|
||||
if !strings.Contains(got, "Waiting for upgrade to complete") {
|
||||
t.Error("should contain waiting message")
|
||||
}
|
||||
if strings.Contains(got, "Open now?") {
|
||||
t.Error("should not show open prompt while polling")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInModel_EscCancels(t *testing.T) {
|
||||
m := signInModel{
|
||||
modelName: "test:cloud",
|
||||
@@ -67,6 +96,35 @@ func TestSignInModel_EscCancels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradeModel_NoCancelsWithoutPolling(t *testing.T) {
|
||||
m := upgradeModel{
|
||||
modelName: "kimi-k2.6:cloud",
|
||||
requiredPlan: "pro",
|
||||
openNow: true,
|
||||
}
|
||||
|
||||
updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRight})
|
||||
fm := updated.(upgradeModel)
|
||||
if fm.openNow {
|
||||
t.Error("right should select no")
|
||||
}
|
||||
if fm.polling {
|
||||
t.Error("right should not start polling")
|
||||
}
|
||||
|
||||
updated, cmd := fm.Update(tea.KeyMsg{Type: tea.KeyEnter})
|
||||
fm = updated.(upgradeModel)
|
||||
if !fm.cancelled {
|
||||
t.Error("enter on no should cancel")
|
||||
}
|
||||
if fm.polling {
|
||||
t.Error("enter on no should not start polling")
|
||||
}
|
||||
if cmd == nil {
|
||||
t.Error("enter on no should quit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignInModel_CtrlCCancels(t *testing.T) {
|
||||
m := signInModel{
|
||||
modelName: "test:cloud",
|
||||
|
||||
Loaded 100 of 1319 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user